diff --git a/backend/build.gradle b/backend/build.gradle index c567a51d1..e57edc20a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -55,6 +55,10 @@ dependencies { } tasks.named('test') { + // Explicit database checks must exercise the current database, not cached results. + if (System.getenv('WARDEN_REVIEW_DB_TEST') == 'true' || System.getenv('WARDEN_REPAIR_TX_DB_TEST') == 'true') { + outputs.upToDateWhen { false } + } useJUnitPlatform() } diff --git a/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java b/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java new file mode 100644 index 000000000..febf31505 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java @@ -0,0 +1,8 @@ +package net.modtale.config.db; + +import java.util.Map; + +public interface ArtifactManifestStore { + String put(Map entries); + Map get(String identity); +} diff --git a/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java b/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java new file mode 100644 index 000000000..201f94a1c --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java @@ -0,0 +1,40 @@ +package net.modtale.config.db; + +import com.mongodb.MongoWriteException; +import net.modtale.model.project.SecurityManifest; +import org.bson.Document; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mapping.MappingException; +import java.util.*; + +/** Immutable, content-addressed records; raw driver access avoids recursive mapping conversions. */ +public final class MongoArtifactManifestStore implements ArtifactManifestStore { + public static final String COLLECTION = "artifact_manifests"; + private final MongoDatabaseFactory database; + public MongoArtifactManifestStore(MongoDatabaseFactory database) { this.database = database; } + @Override public String put(Map entries) { + if (!SecurityManifest.valid(entries, false)) throw new MappingException("Invalid artifact manifest"); + var snapshot = Collections.unmodifiableMap(new TreeMap<>(entries)); + String identity = SecurityManifest.identity(snapshot); + var values = new ArrayList(); + snapshot.forEach((path, hash) -> values.add(new Document("path", path).append("sha256", hash))); + try { + database.getMongoDatabase().getCollection(COLLECTION).insertOne(new Document("_id", identity) + .append("entries", values).append("createdAt", new Date())); + } catch (MongoWriteException duplicate) { + if (duplicate.getError().getCode() != 11000) throw duplicate; + get(identity); // An existing record must really contain the same immutable manifest. + } + return identity; + } + @Override public Map get(String identity) { + if (!SecurityManifest.digest(identity)) throw new MappingException("Invalid artifact manifest reference"); + Document document = database.getMongoDatabase().getCollection(COLLECTION).find(new Document("_id", identity)).first(); + if (document == null) throw new MappingException("Artifact manifest is unavailable"); + var evidence = new SecurityEvidenceConverters.Read().convert(new Document("entryHashes", document.get("entries"))); + var entries = evidence.entryHashes(); + if (!SecurityManifest.valid(entries, false) || !identity.equals(SecurityManifest.identity(entries))) + throw new MappingException("Artifact manifest integrity check failed"); + return Collections.unmodifiableMap(new TreeMap<>(entries)); + } +} diff --git a/backend/src/main/java/net/modtale/config/db/MongoConfig.java b/backend/src/main/java/net/modtale/config/db/MongoConfig.java index a839e36e7..b4750a80e 100644 --- a/backend/src/main/java/net/modtale/config/db/MongoConfig.java +++ b/backend/src/main/java/net/modtale/config/db/MongoConfig.java @@ -12,8 +12,19 @@ public class MongoConfig { @Bean + public ArtifactManifestStore artifactManifestStore(org.springframework.data.mongodb.MongoDatabaseFactory database) { + return new MongoArtifactManifestStore(database); + } + + @Bean + public MongoCustomConversions mongoCustomConversions(ArtifactManifestStore store) { + return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter(), + new SecurityEvidenceConverters.Write(store), new SecurityEvidenceConverters.Read(store))); + } + public MongoCustomConversions mongoCustomConversions() { - return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter())); + return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter(), + new SecurityEvidenceConverters.Write(), new SecurityEvidenceConverters.Read())); } @ReadingConverter diff --git a/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java b/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java new file mode 100644 index 000000000..ca43784ab --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java @@ -0,0 +1,31 @@ +package net.modtale.config.db; + +import net.modtale.model.project.SecurityManifest; +import java.util.*; + +/** Existing Map-based consumers still inspect real verified entries before granting clearance. */ +final class ReferencedArtifactManifest extends AbstractMap { + final ArtifactManifestStore store; + final String identity; + private volatile Map loaded; + ReferencedArtifactManifest(ArtifactManifestStore store, String identity) { + if (store == null || !SecurityManifest.digest(identity)) throw new IllegalArgumentException("Invalid artifact manifest reference"); + this.store = store; this.identity = identity; + } + private Map load() { + var snapshot = loaded; + if (snapshot != null) return snapshot; + synchronized (this) { + if (loaded == null) { + try { + var entries = store.get(identity); + if (!SecurityManifest.valid(entries, false) || !identity.equals(SecurityManifest.identity(entries))) + throw new IllegalStateException("Artifact manifest integrity check failed"); + loaded = Collections.unmodifiableMap(new TreeMap<>(entries)); + } catch (RuntimeException unavailable) { throw new SecurityManifest.Unavailable(unavailable); } + } + return loaded; + } + } + @Override public Set> entrySet() { return load().entrySet(); } +} diff --git a/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java b/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java new file mode 100644 index 000000000..72377eba9 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java @@ -0,0 +1,78 @@ +package net.modtale.config.db; + +import java.util.*; +import net.modtale.model.project.ScanResult.SecurityEvidence; +import net.modtale.model.project.SecurityManifest; +import org.bson.Document; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.mapping.MappingException; + +public final class SecurityEvidenceConverters { + private SecurityEvidenceConverters() {} + + @WritingConverter + public static final class Write implements Converter { + private final ArtifactManifestStore store; + public Write() { this(null); } + public Write(ArtifactManifestStore store) { this.store = store; } + @Override public Document convert(SecurityEvidence evidence) { + if (store != null && evidence.entryHashes() instanceof ReferencedArtifactManifest reference && reference.store == store) + return header(evidence).append("manifestRef", reference.identity); + if (!SecurityManifest.valid(evidence.entryHashes(), true)) throw new MappingException("Invalid or oversized security manifest"); + if (store != null && evidence.entryHashes() != null && !evidence.entryHashes().isEmpty()) + return header(evidence).append("manifestRef", store.put(evidence.entryHashes())); + var entries = new ArrayList(); + if (evidence.entryHashes() != null) evidence.entryHashes().forEach((path, hash) -> + entries.add(new Document("path", path).append("sha256", hash))); + return header(evidence).append("entryHashes", entries); + } + private Document header(SecurityEvidence evidence) { + return new Document("policyVersion", evidence.policyVersion()) + .append("artifactSha256", evidence.artifactSha256()) + .append("contentSha256", evidence.contentSha256()) + .append("complete", evidence.complete()) + .append("clearanceGranted", evidence.clearanceGranted()) + .append("reviewState", evidence.reviewState()); + } + } + + @ReadingConverter + public static final class Read implements Converter { + private final ArtifactManifestStore store; + public Read() { this(null); } + public Read(ArtifactManifestStore store) { this.store = store; } + @Override public SecurityEvidence convert(Document source) { + if (source.containsKey("manifestRef")) { + if (source.containsKey("entryHashes") || store == null || !(source.get("manifestRef") instanceof String identity) + || !SecurityManifest.digest(identity)) throw new MappingException("Invalid artifact manifest reference"); + return evidence(source, new ReferencedArtifactManifest(store, identity)); + } + var entries = new LinkedHashMap(); + Object stored = source.get("entryHashes"); + if (stored instanceof List list) { + if (list.size() > 20_000) throw new MappingException("Security manifest exceeds entry limit"); + for (Object value : list) { + if (!(value instanceof Document entry) || !(entry.get("path") instanceof String path) + || !(entry.get("sha256") instanceof String hash) || entries.putIfAbsent(path, hash) != null) + throw new MappingException("Invalid security manifest entry"); + } + } else if (stored instanceof Map map) { + if (map.size() > 20_000) throw new MappingException("Security manifest exceeds entry limit"); + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String path) || !(entry.getValue() instanceof String hash)) + throw new MappingException("Invalid legacy security manifest entry"); + entries.put(path, hash); + } + } else if (stored != null) throw new MappingException("Invalid security manifest"); + if (!SecurityManifest.valid(entries, true)) throw new MappingException("Invalid or oversized security manifest"); + return evidence(source, entries); + } + private SecurityEvidence evidence(Document source, Map entries) { + return new SecurityEvidence(source.getString("policyVersion"), source.getString("artifactSha256"), + source.getString("contentSha256"), Boolean.TRUE.equals(source.get("complete")), + Boolean.TRUE.equals(source.get("clearanceGranted")), source.getString("reviewState"), entries); + } + } +} diff --git a/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java b/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java new file mode 100644 index 000000000..68039f39b --- /dev/null +++ b/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java @@ -0,0 +1,32 @@ +package net.modtale.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import java.util.*; + +@ConfigurationProperties(prefix="app.warden.repair") +public record AppReviewRepairProperties(@DefaultValue("false") boolean enabled,@DefaultValue("") String activeKey, + Map signingKeys,@DefaultValue("1") int concurrency,@DefaultValue("300000") long preparationLifetimeMillis) { + public AppReviewRepairProperties { + signingKeys=signingKeys==null?Map.of():Map.copyOf(signingKeys); + if(enabled) { + if(concurrency<1 || concurrency>2 || preparationLifetimeMillis<1000 || preparationLifetimeMillis>900000 + || activeKey==null || !signingKeys.containsKey(activeKey) || signingKeys.isEmpty() || signingKeys.size()>8)throw invalid(); + decode(signingKeys); + } + } + public Map decodedKeys(){if(!enabled)throw invalid();return decode(signingKeys);} + private static Map decode(Map keys) { + var decoded=new HashMap(); + keys.forEach((id,value)->{ + try { + if(id==null || !id.matches("[A-Za-z0-9_-]{1,64}") || value==null || value.length()>172)throw invalid(); + byte[] key=Base64.getDecoder().decode(value); + if(key.length<32 || key.length>128 || !Base64.getEncoder().encodeToString(key).equals(value))throw invalid(); + decoded.put(id,key); + } catch(IllegalArgumentException invalid){throw invalid();} + });return Map.copyOf(decoded); + } + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review repair settings");} + @Override public String toString(){return "ReviewRepairSettings[enabled="+enabled+"]";} +} diff --git a/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java b/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java index 1068854be..fca64a432 100644 --- a/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java +++ b/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java @@ -8,7 +8,7 @@ public record AppWardenProperties( @DefaultValue("http://localhost:8081") String url, @DefaultValue("") String apiKey, @DefaultValue("true") boolean enabled, - @DefaultValue("3") int maxAttempts, - @DefaultValue("75") long requestTimeoutSeconds + @DefaultValue("1") int maxAttempts, + @DefaultValue("600") long requestTimeoutSeconds ) { } diff --git a/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java b/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java new file mode 100644 index 000000000..3c56c7e4f --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java @@ -0,0 +1,167 @@ +package net.modtale.controller.admin; + +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.WardenClientService; +import net.modtale.service.storage.StorageService; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +@RestController +@RequestMapping("/api/v1/admin/projects/{id}/versions/{version}") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") +public class ArtifactInspectionController { + private final ProjectService projects; + private final StorageService storage; + private final WardenClientService inspector; + public ArtifactInspectionController(ProjectService projects, StorageService storage, WardenClientService inspector) { + this.projects=projects;this.storage=storage;this.inspector=inspector; + } + @GetMapping("/structure") + public ResponseEntity> structure(@PathVariable String id,@PathVariable String version, @RequestHeader(value="If-Match", required=false) String expected) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(inspect(id,version,null,expected).paths()); + } + @GetMapping(value="/file",produces=MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity file(@PathVariable String id,@PathVariable String version,@RequestParam String path, @RequestHeader(value="If-Match", required=false) String expected) { + var response=inspect(id,version,path,expected); + String prefix="JVM_BYTECODE".equals(response.format()) ? "// JVM bytecode of the uploaded class. LINE entries refer to original source lines.\n" : ""; + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff").body(prefix+response.content()); + } + public record FileWindow(String identity, String content, String format, int start, int end, int totalCharacters, + int firstLine, boolean lineMatched, boolean representationComplete, List gaps) {} + @GetMapping("/file-window") + public ResponseEntity window(@PathVariable String id, @PathVariable String version, @RequestParam String path, + @RequestParam(defaultValue="0") int offset, @RequestParam(defaultValue="32000") int characters, + @RequestParam(defaultValue="0") int sourceLine, @RequestParam(required=false) String identity, + @RequestHeader(value="If-Match",required=false) String expected) { + Project project = projects.getRawProjectById(id); + if (project == null || project.getVersions() == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project, expected); + String snapshot = ProjectReviewSnapshot.token(project); + if (path == null || path.isBlank() || path.length() > 8192 || offset < 0 || offset > 4_000_000 + || characters < 1 || characters > 32000 || sourceLine < 0 || sourceLine > 4_000_001 + || sourceLine > 0 && offset != 0 || offset > 0 && identity == null || identity != null && !identity.matches("[0-9a-f]{64}")) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid inspection window"); + var selected = requireVersion(project, version); + byte[] bytes = verifiedBytes(selected); + var response = inspector.inspectWindow(bytes, path, offset, characters, sourceLine); + requireUnchanged(id, snapshot); + if (!validWindow(response, selected.getHash(), path, offset, characters, sourceLine)) + throw new ResponseStatusException(HttpStatus.CONFLICT, "A consistent inspection window is unavailable"); + var fields = new ArrayList<>(List.of(response.artifactSha256(), response.path(), response.entrySha256(), response.policyVersion(), + response.representationSha256(), response.format(), Integer.toString(response.totalCharacters()), Boolean.toString(response.representationComplete()))); + fields.addAll(response.gaps()); + StringBuilder binding = new StringBuilder(); fields.forEach(field -> binding.append(field.length()).append(':').append(field)); + String actual = digest(binding.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + if (identity != null && !identity.equals(actual)) throw new ResponseStatusException(HttpStatus.CONFLICT, "Inspection changed; reopen the file"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff") + .body(new FileWindow(actual, response.content(), response.format(), response.start(), response.end(), response.totalCharacters(), + response.firstLine(), response.lineMatched(), response.representationComplete(), response.gaps())); + } + private static boolean validWindow(WardenClientService.InspectionWindow w, String hash, String path, int offset, int characters, int sourceLine) { + return w != null && Objects.equals(hash,w.artifactSha256()) && path.equals(w.path()) + && w.entrySha256() != null && w.entrySha256().matches("[0-9a-f]{64}") && w.representationSha256() != null && w.representationSha256().matches("[0-9a-f]{64}") + && w.policyVersion() != null && !w.policyVersion().isBlank() && w.policyVersion().length() <= 256 && w.format() != null + && Set.of("JVM_BYTECODE","TEXT_RESOURCE","JSON_RESOURCE","VALIDATED_RASTER","OPAQUE_RESOURCE","UNREPRESENTED").contains(w.format()) + && w.start() >= 0 && (sourceLine != 0 || offset == w.start()) && w.end() >= w.start() && w.end() <= w.totalCharacters() && w.totalCharacters() <= 4_000_000 + && (w.start() == w.totalCharacters() || w.end() > w.start()) && w.firstLine() >= 1 && w.firstLine() <= w.start() + 1 + && w.content() != null && w.content().length() == w.end()-w.start() && w.content().length() <= characters + && w.gaps() != null && w.gaps().size() <= 8 && w.gaps().stream().allMatch(gap -> gap != null && gap.length() <= 512) + && (!w.representationComplete() || w.gaps().isEmpty() && !Set.of("OPAQUE_RESOURCE","UNREPRESENTED").contains(w.format())); + } + private static String digest(byte[] bytes) { + try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + private byte[] verifiedBytes(ProjectVersion version) { + if (version.getFileUrl() == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + byte[] bytes = storage.download(version.getFileUrl()); + if (!Objects.equals(digest(bytes),version.getHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"Stored artifact hash mismatch"); + return bytes; + } + public record FileChange(String path, String change) {} + public record ArtifactChanges(String reviewToken, String baselineVersion, boolean contextComparable, boolean contextChanged, + int added, int modified, int removed, int unchanged, List files) {} + + @GetMapping("/changes") + public ResponseEntity changes(@PathVariable String id, @PathVariable String version, @RequestHeader(value="If-Match", required=false) String expected) { + Project project=projects.getRawProjectById(id); + if(project==null || project.getVersions()==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project, expected); + String snapshot = ProjectReviewSnapshot.token(project); + ProjectVersion current = requireVersion(project, version); + ProjectVersion baseline=project.getVersions().stream().filter(Objects::nonNull).filter(v -> v.getReviewStatus()==ProjectVersion.ReviewStatus.APPROVED + && !Objects.equals(v.getId(),current.getId())) + .max(Comparator.comparingLong(ProjectVersion::getSecurityApprovedAt)).orElse(null); + if(baseline==null) return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(new ArtifactChanges(snapshot,null,false,false,0,0,0,0,List.of())); + requireVersion(project, baseline.getVersionNumber()); + var before=inspect(baseline,null); + var after=inspect(current,null); + requireUnchanged(id, snapshot); + if(!validManifest(before.entryHashes()) || !validManifest(after.entryHashes()) || before.policyVersion()==null + || !before.policyVersion().equals(after.policyVersion())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"A consistent artifact comparison is unavailable"); + String priorContext=baseline.getApprovedSecurityContextSha256(); + String currentContext=net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(current); + boolean comparable=priorContext!=null && currentContext!=null + && priorContext.equals(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(baseline)); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(compare(snapshot,baseline.getVersionNumber(), + comparable, comparable && !priorContext.equals(currentContext),before.entryHashes(),after.entryHashes())); + } + private static boolean validManifest(Map entries) { + return net.modtale.model.project.SecurityManifest.valid(entries, false); + } + static ArtifactChanges compare(String snapshot, String baseline, boolean comparable, boolean contextChanged, + Map before, Map after) { + var paths=new TreeSet(); paths.addAll(before.keySet()); paths.addAll(after.keySet()); + var changes=new ArrayList(); int added=0,modified=0,removed=0,unchanged=0; + for(String path:paths) { + String change; + if(!before.containsKey(path)) {change="ADDED";added++;} + else if(!after.containsKey(path)) {change="REMOVED";removed++;} + else if(!Objects.equals(before.get(path),after.get(path))) {change="MODIFIED";modified++;} + else {change="UNCHANGED";unchanged++;} + changes.add(new FileChange(path,change)); + } + return new ArtifactChanges(snapshot,baseline,comparable,contextChanged,added,modified,removed,unchanged,List.copyOf(changes)); + } + private WardenClientService.InspectionResponse inspect(String id,String number,String path,String expected) { + Project project=projects.getRawProjectById(id); + if(project==null || project.getVersions()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project, expected); + String snapshot = ProjectReviewSnapshot.token(project); + var result = inspect(requireVersion(project, number), path); + requireUnchanged(id, snapshot); + return result; + } + private ProjectVersion requireVersion(Project project, String number) { + var matches = project.getVersions().stream().filter(Objects::nonNull) + .filter(v -> Objects.equals(v.getVersionNumber(), number)).toList(); + if (matches.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + if (matches.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "Version identity is ambiguous"); + return matches.getFirst(); + } + private void requireUnchanged(String id, String snapshot) { + var current = projects.getRawProjectById(id); + if (current == null || current.getVersions() == null) throw ProjectReviewSnapshot.conflict(); + ProjectReviewSnapshot.requireCurrent(current, snapshot); + } + private WardenClientService.InspectionResponse inspect(ProjectVersion version, String path) { + if(version.getFileUrl()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND); + byte[] bytes=storage.download(version.getFileUrl()); + try { + String actual=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + if(!actual.equals(version.getHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"Stored artifact hash mismatch"); + } catch(java.security.NoSuchAlgorithmException impossible) {throw new IllegalStateException(impossible);} + var response=inspector.inspectFile(bytes,"artifact.zip",path); + if(response==null || !Objects.equals(version.getHash(),response.artifactSha256())) { + throw new ResponseStatusException(HttpStatus.CONFLICT,"Artifact contents no longer match this version"); + } + return response; + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/DependencyInspectionController.java b/backend/src/main/java/net/modtale/controller/admin/DependencyInspectionController.java new file mode 100644 index 000000000..d6cecfd75 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/DependencyInspectionController.java @@ -0,0 +1,87 @@ +package net.modtale.controller.admin; + +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectVersion; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.function.Supplier; + +@RestController +@RequestMapping("/api/v1/admin/projects/{id}/version-ids/{versionId}") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") +public class DependencyInspectionController { + private final ProjectService projects; + private final Supplier sources; + private final DependencyArtifactVerifier verifier; + @Autowired public DependencyInspectionController(ProjectService projects,MongoTemplate mongo,net.modtale.service.storage.StorageService storage) { + this(projects,()->new DependencyReviewSource(mongo),new DependencyArtifactVerifier(storage)); + } + DependencyInspectionController(ProjectService projects,Supplier sources,DependencyArtifactVerifier verifier) { + this.projects=projects;this.sources=sources;this.verifier=verifier; + } + public record ByteInspection(String reviewToken,String inventoryIdentity,DependencyArtifactVerifier.Result verification) {} + @GetMapping("/dependency-bytes") + public ResponseEntity verifyBytes(@PathVariable String id,@PathVariable String versionId, + @RequestParam String inventoryIdentity,@RequestHeader(value="If-Match",required=false) String expected) { + if(inventoryIdentity==null||!inventoryIdentity.matches("[0-9a-f]{64}")) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid dependency inventory identity"); + var before=inspect(id,versionId,expected).getBody(); + if(!before.inventory().resolved()||!inventoryIdentity.equals(before.inventory().identity())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"Dependency inventory changed; inspect it again"); + var verification=verifier.verify(before.inventory()); + // Use a fresh database budget and observations after storage reads, never the pre-read cache. + var after=inspect(id,versionId,expected).getBody(); + if(!after.inventory().resolved()||!inventoryIdentity.equals(after.inventory().identity()) + ||verification==null||!inventoryIdentity.equals(verification.inventoryIdentity())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"Dependency inventory changed during byte verification"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff") + .body(new ByteInspection(after.reviewToken(),inventoryIdentity,verification)); + } + private static boolean sameDeclarations(ProjectVersion version,DependencyReviewGraph.Snapshot root) { + try { + var declarations=new ArrayList(); + if(version.getDependencies()!=null)for(var d:version.getDependencies()) + declarations.add(new DependencyReviewGraph.Dependency(d.getSource(),d.getDependencyType(), + new DependencyReviewGraph.Reference(d.getProjectId(),d.getVersionNumber()))); + return declarations.equals(root.dependencies()); + } catch(RuntimeException invalid) {return false;} + } + public record Inspection(String reviewToken,boolean artifactBytesVerified,DependencyReviewGraph.Inventory inventory) {} + @GetMapping("/dependencies") + public ResponseEntity inspect(@PathVariable String id,@PathVariable String versionId, + @RequestHeader(value="If-Match",required=false) String expected) { + try {new DependencyReviewGraph.Reference(id,versionId);} + catch(IllegalArgumentException invalid){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid dependency inspection identity");} + Project project=projects.getRawProjectById(id); + if(project==null||project.getVersions()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project,expected); + String token=ProjectReviewSnapshot.token(project); + var matches=project.getVersions().stream().filter(Objects::nonNull).filter(v->versionId.equals(v.getId())).toList(); + if(matches.isEmpty())throw new ResponseStatusException(HttpStatus.NOT_FOUND); + if(matches.size()!=1)throw ProjectReviewSnapshot.conflict(); + ProjectVersion version=matches.getFirst(); + var source=sources.get();var selected=source.readRoot(id,versionId); + if(selected.state()!=DependencyReviewGraph.State.FOUND)throw new ResponseStatusException(HttpStatus.CONFLICT,"Dependency root is unavailable or ambiguous"); + var root=selected.snapshot(); + if(!id.equals(root.projectId())||!versionId.equals(root.versionId()) + ||!Objects.equals(version.getVersionNumber(),root.versionNumber())||!Objects.equals(version.getHash(),root.artifactSha256()) + ||!Objects.equals(version.getFileUrl(),root.fileReference()) + ||!Objects.equals(ArtifactReviewContext.fingerprint(version),root.contextSha256()) + ||ArtifactReviewContext.hasSupplementalContent(version)!=root.supplementalContent() + ||!sameDeclarations(version,root))throw ProjectReviewSnapshot.conflict(); + var inventory=DependencyReviewGraph.inspect(root,source,DependencyReviewGraph.Limits.defaults()); + Project current=projects.getRawProjectById(id); + if(current==null||current.getVersions()==null)throw ProjectReviewSnapshot.conflict(); + ProjectReviewSnapshot.requireCurrent(current,token); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff") + .body(new Inspection(token,false,inventory)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java b/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java new file mode 100644 index 000000000..7a3675177 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java @@ -0,0 +1,41 @@ +package net.modtale.controller.admin; + +import net.modtale.service.security.issue.FindingReviewService; +import net.modtale.service.user.account.AccountService; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/projects/{projectId}/versions/{versionId}/finding-decisions") +public class FindingReviewController { + private final FindingReviewService reviews; + private final AccountService accounts; + public FindingReviewController(FindingReviewService reviews, AccountService accounts) { + this.reviews = reviews; this.accounts = accounts; + } + @GetMapping + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity history(@PathVariable String projectId, + @PathVariable String versionId, @RequestHeader("If-Match") String token, + @RequestParam(defaultValue = "0") int offset) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.history(projectId, versionId, token, offset)); + } + @PostMapping + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") + public ResponseEntity record(@PathVariable String projectId, + @PathVariable String versionId, @RequestHeader("If-Match") String token, + @RequestBody FindingReviewService.Request request) { + var actor = accounts.requireCurrentUser("recording finding decisions"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.record(projectId, versionId, token, actor.getId(), request)); + } + public record Revocation(String rationale) {} + @PostMapping("/{decisionId}/revoke") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") + public ResponseEntity revoke(@PathVariable String projectId, + @PathVariable String versionId, @PathVariable String decisionId, + @RequestHeader("If-Match") String token, @RequestBody Revocation request) { + var actor = accounts.requireCurrentUser("revoking finding decisions"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.revoke(projectId, versionId, token, actor.getId(), decisionId, request.rationale())); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java b/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java new file mode 100644 index 000000000..d3e4d2cfe --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java @@ -0,0 +1,30 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; +import net.modtale.service.admin.review.ModerationQueueCursor; +import net.modtale.service.admin.review.ModerationQueuePageReader; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/v1/admin/verification/queue") +public class ModerationQueuePageController { + public record Page(List items,String nextCursor,int unavailableItems,String order,ModerationQueuePageReader.Filter filter) {} + private final ModerationQueuePageReader reader; + public ModerationQueuePageController(ModerationQueuePageReader reader) {this.reader=reader;} + @GetMapping("/page") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit,@RequestParam(defaultValue="ALL") ModerationQueuePageReader.Filter filter) { + ModerationQueuePageReader.Cursor position; + try { + if(limit<1 || limit>50)throw new IllegalArgumentException(); + position=ModerationQueueCursor.decode(cursor); + if(filter==null || position!=null && position.filter()!=filter)throw new IllegalArgumentException(); + } catch(IllegalArgumentException invalid) {throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid queue pagination");} + var page=reader.page(position,limit,filter); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(page.items(),ModerationQueueCursor.encode(page.next()),page.unavailableItems(),"PROJECT_VERSION",filter)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java b/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java new file mode 100644 index 000000000..6a00c8165 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java @@ -0,0 +1,19 @@ +package net.modtale.controller.admin; + +import net.modtale.service.security.issue.PriorFindingReasoningService; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/projects/{projectId}/versions/{versionId}/prior-finding-reasoning") +public class PriorFindingReasoningController { + private final PriorFindingReasoningService reasoning; + public PriorFindingReasoningController(PriorFindingReasoningService reasoning) { this.reasoning=reasoning; } + @GetMapping + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@PathVariable String projectId,@PathVariable String versionId, + @RequestParam String sourceVersionId,@RequestParam int issueIndex,@RequestHeader("If-Match") String token) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reasoning.read(projectId,versionId,sourceVersionId,issueIndex,token)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java b/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java index 5426e1897..e7a098d7b 100644 --- a/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java +++ b/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java @@ -4,7 +4,9 @@ import java.util.List; import net.modtale.model.dto.admin.AdminProjectDTO; import net.modtale.model.dto.admin.AdminProjectReviewDTO; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; +import org.springframework.http.ProblemDetail; +import org.springframework.http.HttpStatus; +import org.springframework.http.CacheControl; import net.modtale.model.dto.project.ProjectSummaryDTO; import net.modtale.model.dto.request.admin.RejectReasonRequest; import net.modtale.model.project.Project; @@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -45,8 +48,10 @@ public ProjectManagementController( @GetMapping("/verification/queue") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") - public ResponseEntity> getVerificationQueue() { - return ResponseEntity.ok(projectReviewAdminService.getVerificationQueue()); + public ResponseEntity getVerificationQueue() { + return ResponseEntity.status(HttpStatus.GONE).cacheControl(CacheControl.noStore()) + .header("Link", "; rel=\"successor-version\"") + .body(ProblemDetail.forStatusAndDetail(HttpStatus.GONE, "Use the paginated moderation queue endpoint. Refresh the application to load the current review interface.")); } @GetMapping("/projects/{id}/review-details") @@ -63,41 +68,41 @@ public ResponseEntity getProjectById(@PathVariable String id) { @PutMapping("/projects/{id}/raw") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_RAW_EDIT', authentication)") - public ResponseEntity updateRawProject(@PathVariable String id, @RequestBody Project updatedProject) { + public ResponseEntity updateRawProject(@PathVariable String id, @RequestBody java.util.Map metadata, @RequestHeader("If-Match") String token) { User currentUser = accountService.requireCurrentUser("editing raw project data"); - projectAdminOperationsService.updateRawProject(currentUser.getId(), id, updatedProject); + projectAdminOperationsService.updateRawProject(currentUser.getId(), id, metadata, token); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/publish") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity publishProject(@PathVariable String id) { + public ResponseEntity publishProject(@PathVariable String id, @RequestHeader("If-Match") String reviewToken, @RequestParam(required = false) String versionId) { User currentUser = accountService.requireCurrentUser("publishing projects"); - projectReviewAdminService.publishProject(currentUser, id); + projectReviewAdminService.publishProject(currentUser, id, reviewToken, versionId); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/versions/{versionId}/approve") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity approveVersion(@PathVariable String id, @PathVariable String versionId) { + public ResponseEntity approveVersion(@PathVariable String id, @PathVariable String versionId, @RequestHeader("If-Match") String reviewToken) { User currentUser = accountService.requireCurrentUser("approving project versions"); - projectReviewAdminService.approveVersion(currentUser, id, versionId); + projectReviewAdminService.approveVersion(currentUser, id, versionId, reviewToken); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/versions/{versionId}/reject") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity rejectVersion(@PathVariable String id, @PathVariable String versionId, @Valid @RequestBody RejectReasonRequest requestPayload) { + public ResponseEntity rejectVersion(@PathVariable String id, @PathVariable String versionId, @Valid @RequestBody RejectReasonRequest requestPayload, @RequestHeader("If-Match") String reviewToken) { User currentUser = accountService.requireCurrentUser("rejecting project versions"); - projectReviewAdminService.rejectVersion(currentUser, id, versionId, requestPayload.getReason()); + projectReviewAdminService.rejectVersion(currentUser, id, versionId, requestPayload.getReason(), reviewToken); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/reject") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity rejectProject(@PathVariable String id, @Valid @RequestBody RejectReasonRequest requestPayload) { + public ResponseEntity rejectProject(@PathVariable String id, @Valid @RequestBody RejectReasonRequest requestPayload, @RequestHeader("If-Match") String reviewToken) { User currentUser = accountService.requireCurrentUser("rejecting projects"); - projectReviewAdminService.rejectProject(currentUser, id, requestPayload.getReason()); + projectReviewAdminService.rejectProject(currentUser, id, requestPayload.getReason(), reviewToken); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java new file mode 100644 index 000000000..1b7889b48 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java @@ -0,0 +1,31 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/verification/cancellations") +@ConditionalOnProperty(name="app.warden.repair.cancellation.enabled",havingValue="true") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication) and @apiSecurity.hasAdminPermission('PROJECT_VERSION_RESCAN', authentication)") +public class ReviewCancellationController { + public record Prepare(String isolationId) {} + private final ReviewCancellationAccess access; + public ReviewCancellationController(ReviewCancellationAccess access){this.access=access;} + @GetMapping("/capabilities") public ResponseEntity capabilities(){return response(access.capability());} + @GetMapping("/targets/{id}") public ResponseEntity preview(@PathVariable String id){return response(access.preview(id));} + @GetMapping("/operations/{id}") public ResponseEntity recover(@PathVariable String id){return response(access.recover(id));} + @PostMapping("/prepare") public ResponseEntity prepare(@RequestBody Prepare request){return response(access.prepare(request==null?null:request.isolationId()));} + @PostMapping("/execute") public ResponseEntity execute(@RequestBody ReviewOrphanCancellationJournal.Prepared request){return response(access.execute(request));} + @PostMapping("/receipt") public ResponseEntity receipt(@RequestBody ReviewOrphanCancellationJournal.Prepared request){return response(access.receipt(request));} + @PostMapping("/checks") public ResponseEntity check(@RequestBody ReviewCancellationAccess.Check request){return response(access.check(request));} + @PostMapping("/checks/receipt") public ResponseEntity checkReceipt(@RequestBody ReviewCancellationAccess.Check request){return response(access.checkReceipt(request));} + @PostMapping("/checks/history") public ResponseEntity history(@RequestBody ReviewCancellationAccess.History request){return response(access.history(request));} + private static ResponseEntity response(T value){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value);} + @ExceptionHandler(SecurityException.class) ResponseEntity forbidden(){return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler({IllegalArgumentException.class,org.springframework.http.converter.HttpMessageNotReadableException.class}) ResponseEntity invalid(){return ResponseEntity.badRequest().cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(IllegalStateException.class) ResponseEntity conflict(){return ResponseEntity.status(HttpStatus.CONFLICT).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(com.mongodb.MongoException.class) ResponseEntity unavailable(){return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()).build();} +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java new file mode 100644 index 000000000..23d94eb62 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java @@ -0,0 +1,38 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/v1/admin/verification/origins") +public class ReviewOriginController { + public record Position(String projectIdType,String projectId,int versionIndex) {} + public record Item(Position position,String versionId,boolean ambiguousVersion,String requestId,String jobId,ReviewOriginInventory.OriginState originState) {} + public record Page(List items,String nextCursor,int examinedSlots,String scope) {} + private final ReviewOriginInventory inventory; + public ReviewOriginController(ReviewOriginInventory inventory){this.inventory=inventory;} + @GetMapping("/page") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit) { + RemoteReviewDiscovery.Cursor position; + try { + if(limit<1 || limit>64)throw new IllegalArgumentException(); + position=ReviewOriginCursor.decode(cursor); + }catch(IllegalArgumentException invalid){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid origin pagination");} + ReviewOriginInventory.Page page; + try {page=inventory.page(position,limit);} + catch(RuntimeException unavailable){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Review origin inventory unavailable");} + var items=page.items().stream().map(item->{ + boolean oid=item.projectId() instanceof ObjectId; + return new Item(new Position(oid?"OBJECT_ID":"STRING",oid?((ObjectId)item.projectId()).toHexString():(String)item.projectId(),item.versionIndex()), + item.versionId(),item.ambiguousVersion(),item.requestId(),item.jobId(),item.originState()); + }).toList(); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(items,ReviewOriginCursor.encode(page.next()),page.examined(),"RETAINED_REVIEW_ORIGINS")); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java new file mode 100644 index 000000000..486e64783 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java @@ -0,0 +1,29 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/verification/repairs") +@ConditionalOnProperty(name="app.warden.repair.enabled",havingValue="true") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication) and @apiSecurity.hasAdminPermission('PROJECT_VERSION_RESCAN', authentication)") +public class ReviewRepairController { + private final ReviewRepairAccess access; + public ReviewRepairController(ReviewRepairAccess access){this.access=access;} + @GetMapping("/capabilities") public ResponseEntity capabilities(){return response(access.capability());} + @GetMapping("/operations") public ResponseEntity operations(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit){return response(access.operations(cursor,limit));} + @GetMapping("/operations/{id}") public ResponseEntity recover(@PathVariable String id){return response(access.recover(id));} + @PostMapping("/inspect") public ResponseEntity inspect(@RequestBody ReviewRepairAccess.Inspect request){return response(access.inspect(request));} + @PostMapping("/prepare") public ResponseEntity prepare(@RequestBody ReviewRepairAccess.Prepare request){return response(access.prepare(request));} + @PostMapping("/execute") public ResponseEntity execute(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.execute(request));} + @PostMapping("/close-expired") public ResponseEntity closeExpired(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.closeExpired(request));} + @PostMapping("/receipt") public ResponseEntity receipt(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.receipt(request));} + private static ResponseEntity response(T value){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value);} + @ExceptionHandler(SecurityException.class) ResponseEntity forbidden(){return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler({IllegalArgumentException.class,org.springframework.http.converter.HttpMessageNotReadableException.class}) ResponseEntity invalid(){return ResponseEntity.badRequest().cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(IllegalStateException.class) ResponseEntity conflict(){return ResponseEntity.status(HttpStatus.CONFLICT).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(com.mongodb.MongoException.class) ResponseEntity unavailable(){return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()).build();} +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java new file mode 100644 index 000000000..94c08458c --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java @@ -0,0 +1,35 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/v1/admin/verification/diagnostics") +public class ReviewStateDiagnosticController { + public record Position(String projectIdType,String projectId,int versionIndex) {} + public record Item(Position position,String versionId,List reasons) {} + public record Page(List items,String nextCursor,int examinedSlots,String scope) {} + private final ReviewStateDiagnosticReader reader; + public ReviewStateDiagnosticController(ReviewStateDiagnosticReader reader){this.reader=reader;} + @GetMapping("/page") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit) { + RemoteReviewDiscovery.Cursor position; + try { + if(limit<1 || limit>64)throw new IllegalArgumentException(); + position=ReviewStateDiagnosticCursor.decode(cursor); + } catch(IllegalArgumentException invalid) {throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid diagnostic pagination");} + var page=reader.page(position,limit); + var items=page.items().stream().map(item->{ + Object id=item.projectId();boolean objectId=id instanceof ObjectId; + return new Item(new Position(objectId?"OBJECT_ID":"STRING",objectId?((ObjectId)id).toHexString():(String)id,item.versionIndex()),item.versionId(),item.reasons()); + }).toList(); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(items,ReviewStateDiagnosticCursor.encode(page.next()),page.examined(),"PENDING_SCAN_STRUCTURE")); + } +} diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 26d7eb781..5591d7f02 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -196,7 +196,8 @@ public static AdminProjectDTO toAdminDTO(Project project) { project.getProjectRoles(), project.getTeamMembers(), project.getTeamInvites(), - toAdminVersionSummaryDTOs(project.getVersions()) + toAdminVersionSummaryDTOs(project.getVersions()), + net.modtale.service.admin.review.ProjectReviewSnapshot.token(project) ); } @@ -367,7 +368,8 @@ public static AdminProjectVersionSummaryDTO toAdminVersionSummaryDTO(ProjectVers version.getChannel(), version.getReviewStatus(), version.getRejectionReason(), - version.getScanResult() + version.getScanResult(), + net.modtale.service.admin.review.VersionReviewSnapshot.token(version) ); } @@ -388,6 +390,11 @@ public static AdminVerificationQueueItemDTO toVerificationQueueItemDTO(Project p .findFirst() .orElseGet(() -> project.getVersions().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null)); + return toVerificationQueueItemDTO(project, pendingVersion); + } + + public static AdminVerificationQueueItemDTO toVerificationQueueItemDTO(Project project, ProjectVersion pendingVersion) { + if (project == null) return null; return new AdminVerificationQueueItemDTO( project.getId(), project.getTitle(), @@ -417,6 +424,7 @@ private static AdminVerificationQueueScanDTO toVerificationQueueScanDTO(ScanResu return new AdminVerificationQueueScanDTO( scanResult.getStatus(), scanResult.getVerdict(), + scanResult.getScanState(), scanResult.getRiskScore(), scanResult.getKnownIssueCount(), scanResult.getNewIssueCount(), diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java index aea8cd35a..88afed283 100644 --- a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java @@ -44,5 +44,6 @@ public record AdminProjectDTO( List projectRoles, List teamMembers, List teamInvites, - List versions + List versions, + String reviewToken ) {} diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java index 0a0f60779..6374806f2 100644 --- a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java @@ -15,5 +15,6 @@ public record AdminProjectVersionSummaryDTO( ProjectVersion.Channel channel, ProjectVersion.ReviewStatus reviewStatus, String rejectionReason, - ScanResult scanResult + ScanResult scanResult, + String reviewToken ) {} diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java index fa1ff4ab9..1b08802e1 100644 --- a/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java @@ -7,6 +7,7 @@ public record AdminVerificationQueueScanDTO( ScanStatus status, String verdict, + String scanState, int riskScore, int knownIssueCount, int newIssueCount, diff --git a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java index 8fe7cb1e9..c0bb944f7 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java @@ -3,6 +3,49 @@ import java.util.List; public class ProjectVersion { + private ReviewReplacement versionMutation; + @com.fasterxml.jackson.annotation.JsonIgnore + public ReviewReplacement getVersionMutation() { return versionMutation; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setVersionMutation(ReviewReplacement value) { versionMutation=value; } + private RemoteReviewBinding retainedRemoteReview; + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewBinding getRetainedRemoteReview() { return retainedRemoteReview; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRetainedRemoteReview(RemoteReviewBinding value) { retainedRemoteReview=value; } + private String replacementSecurityHold; + @com.fasterxml.jackson.annotation.JsonIgnore + public String getReplacementSecurityHold() { return replacementSecurityHold; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReplacementSecurityHold(String value) { replacementSecurityHold=value; } + private ReviewReplacement reviewReplacement; + @com.fasterxml.jackson.annotation.JsonIgnore + public ReviewReplacement getReviewReplacement() { return reviewReplacement; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReviewReplacement(ReviewReplacement value) { reviewReplacement=value; } + public record ReviewReplacement(String operationId,String beforeSha256,String requestId) { + public ReviewReplacement { + if(operationId==null || !operationId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || requestId==null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || beforeSha256==null || !beforeSha256.matches("[0-9a-f]{64}"))throw new IllegalArgumentException("Invalid replacement provenance"); + } + } + private ReviewIsolation reviewIsolation; + @com.fasterxml.jackson.annotation.JsonIgnore + public ReviewIsolation getReviewIsolation() { return reviewIsolation; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReviewIsolation(ReviewIsolation value) { reviewIsolation = value; } + public record ReviewIsolation(String operationId, String actorId, String beforeSha256, java.util.Date isolatedAt) { + public ReviewIsolation { + if (operationId == null || !operationId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || actorId == null || actorId.isBlank() || actorId.length() > 256 + || beforeSha256 == null || !beforeSha256.matches("[0-9a-f]{64}") || isolatedAt == null) + throw new IllegalArgumentException("Invalid review isolation provenance"); + isolatedAt = new java.util.Date(isolatedAt.getTime()); + } + @Override public java.util.Date isolatedAt() { return new java.util.Date(isolatedAt.getTime()); } + } + private List modpackConfigs; public List getModpackConfigs() { return modpackConfigs; } public void setModpackConfigs(List value) { modpackConfigs = value; } @@ -22,8 +65,29 @@ public class ProjectVersion { private List incompatibleProjectIds; private Channel channel; + private ScanResult.SecurityEvidence approvedSecurityEvidence; + private long securityApprovedAt; + private String approvedSecurityContextSha256; + public String getApprovedSecurityContextSha256() { return approvedSecurityContextSha256; } + public void setApprovedSecurityContextSha256(String value) { approvedSecurityContextSha256 = value; } + public ScanResult.SecurityEvidence getApprovedSecurityEvidence() { return approvedSecurityEvidence; } + public void setApprovedSecurityEvidence(ScanResult.SecurityEvidence value) { approvedSecurityEvidence = value; } + public long getSecurityApprovedAt() { return securityApprovedAt; } + public void setSecurityApprovedAt(long value) { securityApprovedAt = value; } private ScanResult scanResult; private List approvedIssueBaselines; + private String securityApprovalProjectId; + public String getSecurityApprovalProjectId() { return securityApprovalProjectId; } + public void setSecurityApprovalProjectId(String value) { securityApprovalProjectId = value; } + private java.util.Map approvedReviewOrigins; + public java.util.Map getApprovedReviewOrigins() { return approvedReviewOrigins; } + public void setApprovedReviewOrigins(java.util.Map value) { approvedReviewOrigins = value; } + private String approvedFindingReviewHead; + public String getApprovedFindingReviewHead() { return approvedFindingReviewHead; } + public void setApprovedFindingReviewHead(String value) { approvedFindingReviewHead = value; } + private String findingReviewHead; + public String getFindingReviewHead() { return findingReviewHead; } + public void setFindingReviewHead(String value) { findingReviewHead = value; } private ReviewStatus reviewStatus = ReviewStatus.PENDING; private String rejectionReason; @@ -36,6 +100,8 @@ public enum ReviewStatus { PENDING, SCHEDULED, APPROVED, REJECTED } public static class ApprovedIssueBaseline { private String fingerprint; private String looseFingerprint; + private String evidenceIdentity; + private String reasoningEvidenceIdentity; private String severity; private int scoreImpact; private int confidence; @@ -59,6 +125,13 @@ public ApprovedIssueBaseline( this.approvedAt = approvedAt; } + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + public String getReasoningEvidenceIdentity() { return reasoningEvidenceIdentity; } + public void setReasoningEvidenceIdentity(String value) { reasoningEvidenceIdentity = value; } + + public String getEvidenceIdentity() { return evidenceIdentity; } + public void setEvidenceIdentity(String value) { evidenceIdentity = value; } + public String getFingerprint() { return fingerprint; } public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } diff --git a/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java b/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java new file mode 100644 index 000000000..4c3274f08 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java @@ -0,0 +1,29 @@ +package net.modtale.model.project; + +public record RemoteReviewBinding(String projectId, String versionId, String requestId, int attempt, + String filePath, String artifactSha256, String contextSha256, String policyVersion, + String reviewConfigSha256, String jobId, boolean manualRescan, RemoteReviewOrigin origin) { + public RemoteReviewBinding(String projectId,String versionId,String requestId,int attempt,String filePath,String artifactSha256, + String contextSha256,String policyVersion,String reviewConfigSha256,String jobId) { + this(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,jobId,false); + } + // Legacy records remain readable, but their missing origin cannot authorize remote requests. + public RemoteReviewBinding(String projectId,String versionId,String requestId,int attempt,String filePath,String artifactSha256, + String contextSha256,String policyVersion,String reviewConfigSha256,String jobId,boolean manualRescan) { + this(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,jobId,manualRescan,null); + } + public RemoteReviewBinding { + if (!text(projectId,128) || !text(versionId,128) || !uuid(requestId) || attempt < 1 + || !text(filePath,4096) || !digest(artifactSha256) || !digest(contextSha256) + || policyVersion == null || !policyVersion.matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || !digest(reviewConfigSha256) || jobId != null && !uuid(jobId)) + throw new IllegalArgumentException("Invalid remote review binding"); + } + public RemoteReviewBinding withJobId(String value) { + if (!uuid(value) || jobId != null && !jobId.equals(value)) throw new IllegalArgumentException("Remote job identity cannot change"); + return new RemoteReviewBinding(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,value,manualRescan,origin); + } + private static boolean text(String value,int max) { return value != null && !value.isBlank() && value.length() <= max && value.chars().noneMatch(Character::isISOControl); } + private static boolean digest(String value) { return value != null && value.matches("[0-9a-f]{64}"); } + private static boolean uuid(String value) { return value != null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); } +} diff --git a/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java b/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java new file mode 100644 index 000000000..97517cac1 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java @@ -0,0 +1,8 @@ +package net.modtale.model.project; + +public record RemoteReviewOrigin(String deploymentId,String callerScope) { + public RemoteReviewOrigin { + if(deploymentId==null || !deploymentId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || callerScope==null || !callerScope.matches("[0-9a-f]{64}"))throw new IllegalArgumentException("Invalid remote review origin"); + } +} diff --git a/backend/src/main/java/net/modtale/model/project/ScanResult.java b/backend/src/main/java/net/modtale/model/project/ScanResult.java index 617c47ff7..c4f4500b9 100644 --- a/backend/src/main/java/net/modtale/model/project/ScanResult.java +++ b/backend/src/main/java/net/modtale/model/project/ScanResult.java @@ -6,6 +6,56 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class ScanResult { + private boolean manualRescan; + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean isManualRescan() { return manualRescan; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setManualRescan(boolean value) { manualRescan = value; } + private RemoteReviewStatus remoteStatus; + public record RemoteReviewStatus(String jobId,String state,boolean artifactRetained,long createdAt,long expiresAt,String workState) {} + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewStatus getRemoteStatus() { return remoteStatus; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRemoteStatus(RemoteReviewStatus value) { remoteStatus = value; } + private RemoteReviewPoll remotePoll; + public record RemoteReviewPoll(String token, java.util.Date leaseUntil, java.util.Date nextPollAt) {} + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewPoll getRemotePoll() { return remotePoll; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRemotePoll(RemoteReviewPoll value) { remotePoll = value; } + private RemoteReviewBinding remoteReview; + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewBinding getRemoteReview() { return remoteReview; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRemoteReview(RemoteReviewBinding value) { remoteReview = value; } + private SecurityEvidence securityEvidence; + private boolean artifactVerified; + private String reviewedContextSha256; + @com.fasterxml.jackson.annotation.JsonIgnore + public String getReviewedContextSha256() { return reviewedContextSha256; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReviewedContextSha256(String value) { reviewedContextSha256 = value; } + private java.util.Map reusedReviewOrigins; + @com.fasterxml.jackson.annotation.JsonIgnore + public java.util.Map getReusedReviewOrigins() { return reusedReviewOrigins; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReusedReviewOrigins(java.util.Map value) { reusedReviewOrigins = value; } + private long reusedReviewApprovedAt; + public long getReusedReviewApprovedAt() { return reusedReviewApprovedAt; } + public void setReusedReviewApprovedAt(long value) { reusedReviewApprovedAt = value; } + private String reusedReviewVersion; + public SecurityEvidence getSecurityEvidence() { return securityEvidence; } + public void setSecurityEvidence(SecurityEvidence value) { securityEvidence = value; } + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean isArtifactVerified() { return artifactVerified; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setArtifactVerified(boolean value) { artifactVerified = value; } + public String getReusedReviewVersion() { return reusedReviewVersion; } + public void setReusedReviewVersion(String value) { reusedReviewVersion = value; if (value == null) reusedReviewOrigins = null; } + @JsonIgnoreProperties(ignoreUnknown = true) + public record SecurityEvidence(String policyVersion, String artifactSha256, String contentSha256, + boolean complete, boolean clearanceGranted, String reviewState, java.util.Map entryHashes) {} + private ScanStatus status; private String verdict; private String riskLevel; @@ -14,6 +64,11 @@ public class ScanResult { private int riskScore; private int confidenceScore; private int scanAttempt; + private String scanRequestId; + @com.fasterxml.jackson.annotation.JsonIgnore + public String getScanRequestId() { return scanRequestId; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setScanRequestId(String value) { scanRequestId = value; } private long scanTimestamp; private long holdUntilTimestamp; @@ -204,6 +259,7 @@ public static class ScanIssue { private boolean resolved; private String fingerprint; private boolean knownIssue; + private boolean historicalFileEvidenceIdentical; private boolean escalated; private String baselineVersion; private int baselineScoreImpact; @@ -259,6 +315,9 @@ public ScanIssue() {} public String getFingerprint() { return fingerprint; } public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } + public boolean isHistoricalFileEvidenceIdentical() { return historicalFileEvidenceIdentical; } + public void setHistoricalFileEvidenceIdentical(boolean value) { historicalFileEvidenceIdentical = value; } + public boolean isKnownIssue() { return knownIssue; } public void setKnownIssue(boolean knownIssue) { this.knownIssue = knownIssue; } diff --git a/backend/src/main/java/net/modtale/model/project/SecurityManifest.java b/backend/src/main/java/net/modtale/model/project/SecurityManifest.java new file mode 100644 index 000000000..7a2743770 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/SecurityManifest.java @@ -0,0 +1,44 @@ +package net.modtale.model.project; + +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.*; +import java.util.regex.Pattern; + +/** Shared bounds and identity rules for artifact entry manifests. */ +public final class SecurityManifest { + private static final Pattern SHA256 = Pattern.compile("[0-9a-f]{64}"); + private SecurityManifest() {} + public static final class Unavailable extends IllegalStateException { + public Unavailable(Throwable cause) { super("Artifact manifest is unavailable or invalid", cause); } + } + public static boolean valid(Map entries, boolean allowEmpty) { + try { return validLoaded(entries, allowEmpty); } + catch (Unavailable unavailable) { return false; } + } + private static boolean validLoaded(Map entries, boolean allowEmpty) { + if (entries == null || entries.isEmpty()) return allowEmpty; + if (entries.size() > 20_000) return false; + int characters = 0; + for (var entry : entries.entrySet()) { + String path = entry.getKey(); + if (path == null || path.isBlank() || path.length() > 8192 || !digest(entry.getValue())) return false; + characters += path.length(); + if (characters > 1_000_000) return false; + } + return true; + } + public static boolean digest(String value) { return value != null && SHA256.matcher(value).matches(); } + public static String identity(Map entries) { + if (!valid(entries, false)) throw new IllegalArgumentException("Invalid artifact manifest"); + try { + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + for (var entry : new TreeMap<>(entries).entrySet()) { + hash.update((entry.getKey().length() + ":").getBytes(StandardCharsets.UTF_8)); + hash.update(entry.getKey().getBytes(StandardCharsets.UTF_8)); + hash.update((":" + entry.getValue() + "\n").getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(hash.digest()); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java index 9c3d3dcc7..98f6ce29b 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java @@ -25,8 +25,8 @@ public AdminProjectDTO getProjectById(String id) { return projectAdminQueryService.getProjectById(id); } - public void updateRawProject(String adminId, String id, Project updatedProject) { - projectAdminQueryService.updateRawProject(adminId, id, updatedProject); + public void updateRawProject(String adminId, String id, java.util.Map metadata, String token) { + projectAdminQueryService.updateRawProject(adminId, id, metadata, token); } public void deleteProject(net.modtale.model.user.User adminUser, String id, String reason) { diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java index dd60ad2d7..c90c62f38 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java @@ -8,7 +8,8 @@ import net.modtale.model.project.Project; import net.modtale.model.project.ProjectSort; import net.modtale.model.project.ProjectViewCategory; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.admin.audit.AdminAuditLogger; import net.modtale.service.project.query.ProjectService; import net.modtale.service.project.query.SearchService; @@ -18,18 +19,18 @@ @Service public class ProjectAdminQueryService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final SearchService searchService; private final AdminAuditLogger adminAuditLogger; public ProjectAdminQueryService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, SearchService searchService, AdminAuditLogger adminAuditLogger ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.searchService = searchService; this.adminAuditLogger = adminAuditLogger; @@ -43,13 +44,13 @@ public AdminProjectDTO getProjectById(String id) { return ProjectMapper.toAdminDTO(project); } - public void updateRawProject(String adminId, String id, Project updatedProject) { - Project existing = requireProject(id); - projectService.evictProjectCache(existing); - updatedProject.setId(existing.getId()); - projectRepository.save(updatedProject); - projectService.evictProjectCache(updatedProject); - adminAuditLogger.logAction(adminId, "RAW_UPDATE_PROJECT", existing.getId(), "PROJECT", "Updated via Raw JSON"); + public void updateRawProject(String adminId, String id, java.util.Map metadata, String token) { + ProjectMetadataRepair.validate(metadata); + var snapshot = reviewPersistence.capture(id, token); + if (!reviewPersistence.applyMetadataRepair(snapshot, metadata)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); + adminAuditLogger.logAction(adminId, "RAW_UPDATE_PROJECT", id, "PROJECT", + "Repaired metadata fields: " + String.join(", ", new java.util.TreeSet<>(metadata.keySet()))); } public List searchProjects(String query, boolean deleted) { diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java new file mode 100644 index 000000000..0b0b82b6f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java @@ -0,0 +1,53 @@ +package net.modtale.service.admin.project; + +import java.util.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public final class ProjectMetadataRepair { + private ProjectMetadataRepair() {} + private static final Set STRINGS = Set.of("title", "about", "description", "imageUrl", "bannerUrl", + "repositoryUrl", "license", "hmWikiSlug"); + private static final Set FLAGS = Set.of("customLicenseOpenSource", "allowModpacks", "allowComments", + "hmWikiEnabled", "galleryCarouselEnabled"); + private static final Set LISTS = Set.of("tags", "galleryImages"); + private static final Set MAPS = Set.of("links", "galleryImageCaptions"); + public static void validate(Map metadata) { + if (metadata == null || metadata.isEmpty() || metadata.size() > 17) throw invalid(); + long characters = 0; + for (var entry : metadata.entrySet()) { + String field = entry.getKey(); Object value = entry.getValue(); + if (field == null) throw invalid(); + if (STRINGS.contains(field)) { + if (value != null && !(value instanceof String)) throw invalid(); + if (field.equals("title") && (!(value instanceof String text) || text.isBlank())) throw invalid(); + characters += textSize(value, field.equals("description") ? 100_000 : 4_096); + } else if (FLAGS.contains(field)) { + if (!(value instanceof Boolean)) throw invalid(); + } else if (LISTS.contains(field)) { + if (!(value instanceof List values) || values.size() > 1000) throw invalid(); + for (Object item : values) { + if (!(item instanceof String)) throw invalid(); + characters += textSize(item, 4096); + } + } else if (MAPS.contains(field)) { + if (!(value instanceof Map values) || values.size() > 1000) throw invalid(); + for (var pair : values.entrySet()) { + if (!(pair.getKey() instanceof String) || !(pair.getValue() instanceof String)) throw invalid(); + characters += textSize(pair.getKey(), 4096) + textSize(pair.getValue(), 4096); + } + } else throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Only editable project metadata is accepted. Use the dedicated workflow for versions, access, and review decisions."); + if (characters > 200_000) throw invalid(); + } + } + private static int textSize(Object value, int maximum) { + if (value == null) return 0; + int size = ((String) value).length(); + if (size > maximum) throw invalid(); + return size; + } + private static ResponseStatusException invalid() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Project metadata has invalid fields, types, or size."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java index 204fe5de0..29af16ffb 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java @@ -5,7 +5,8 @@ import net.modtale.exception.ResourceNotFoundException; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectStatus; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.admin.audit.AdminAuditLogger; import net.modtale.service.analytics.ScoringService; import net.modtale.service.communication.NotificationService; @@ -19,7 +20,7 @@ @Service public class ProjectModerationService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectRetentionService projectRetentionService; private final ProjectDeletionService projectDeletionService; @@ -30,7 +31,7 @@ public class ProjectModerationService { private final AdminAuditLogger adminAuditLogger; public ProjectModerationService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectRetentionService projectRetentionService, ProjectDeletionService projectDeletionService, @@ -40,7 +41,7 @@ public ProjectModerationService( ProjectVersionAccessService projectVersionAccessService, AdminAuditLogger adminAuditLogger ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectRetentionService = projectRetentionService; this.projectDeletionService = projectDeletionService; @@ -85,10 +86,12 @@ public void restoreProject(net.modtale.model.user.User adminUser, String id, Pro public void unlistProject(net.modtale.model.user.User adminUser, String id, String reason) { Project targetProject = requireProject(id); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(targetProject)); + targetProject = snapshot.project(); targetProject.setStatus(ProjectStatus.UNLISTED); targetProject.setExpiresAt(null); scoringService.markProjectRankingDirty(targetProject); - projectRepository.save(targetProject); + if (!reviewPersistence.unlist(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(targetProject); notificationService.sendNotifcation( @@ -103,18 +106,14 @@ public void unlistProject(net.modtale.model.user.User adminUser, String id, Stri public void deleteProjectVersion(net.modtale.model.user.User adminUser, String id, String versionId) { Project project = requireProject(id); - projectVersionAccessService.requireById(project, versionId, + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + var removed = projectVersionAccessService.requireById(project, versionId, () -> new ResourceNotFoundException("Version not found.")); - project.getVersions().removeIf(version -> { - if (!version.getId().equals(versionId)) { - return false; - } - projectDeletionService.deleteVersionFile(version); - return true; - }); - - projectRepository.save(project); + project.getVersions().removeIf(version -> version.getId().equals(versionId)); + if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + projectDeletionService.deleteVersionFile(removed); adminAuditLogger.logAction(adminUser.getId(), "DELETE_VERSION", id, "VERSION", "VerID: " + versionId); } diff --git a/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java new file mode 100644 index 000000000..01910a5e4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java @@ -0,0 +1,39 @@ +package net.modtale.service.admin.review; + +import org.bson.types.ObjectId; +import java.nio.ByteBuffer; +import java.nio.charset.*; +import java.util.Base64; + +public final class ModerationQueueCursor { + private ModerationQueueCursor() {} + public static String encode(ModerationQueuePageReader.Cursor cursor) { + if(cursor==null)return null; + Object id=cursor.projectId();String type=id instanceof ObjectId?"o":"s"; + String value=id instanceof ObjectId objectId?objectId.toHexString():(String)id; + if(!StandardCharsets.UTF_8.newEncoder().canEncode(value))throw new IllegalArgumentException("Invalid queue cursor"); + String encoded=Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return (cursor.filter()==ModerationQueuePageReader.Filter.ALL?"1.":"2."+cursor.filter().name()+".")+type+"."+cursor.versionIndex()+"."+encoded; + } + public static ModerationQueuePageReader.Cursor decode(String token) { + if(token==null)return null; + if(token.length()>800 || !token.matches("(1|2\\.(SECURITY|OPERATIONS))\\.[os]\\.(0|[1-9][0-9]{0,7})\\.[A-Za-z0-9_-]{1,684}"))throw new IllegalArgumentException("Invalid queue cursor"); + try { + boolean filtered=token.startsWith("2."); + var parts=token.split("\\."); + var filter=filtered?ModerationQueuePageReader.Filter.valueOf(parts[1]):ModerationQueuePageReader.Filter.ALL; + var fields=filtered?new String[]{parts[0],parts[2],parts[3],parts[4]}:parts; + byte[] bytes=Base64.getUrlDecoder().decode(fields[3]); + String value=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + Object id=value; + if(fields[1].equals("o")) { + if(!value.matches("[0-9a-f]{24}"))throw new IllegalArgumentException("Invalid queue cursor"); + id=new ObjectId(value); + } + var cursor=new ModerationQueuePageReader.Cursor(id,Long.parseLong(fields[2]),filter); + if(!encode(cursor).equals(token))throw new IllegalArgumentException("Invalid queue cursor"); + return cursor; + } catch(CharacterCodingException | IllegalArgumentException invalid) {throw new IllegalArgumentException("Invalid queue cursor");} + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java new file mode 100644 index 000000000..98557e2b4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java @@ -0,0 +1,96 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.dto.admin.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +public final class ModerationQueuePageReader { + public enum Filter { ALL, SECURITY, OPERATIONS } + public record Cursor(Object projectId,long versionIndex,Filter filter) { + public Cursor(Object projectId,long versionIndex) {this(projectId,versionIndex,Filter.ALL);} + public Cursor { + if (!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.codePointCount(0,s.length())<=128) + || versionIndex<0 || versionIndex>16*1024*1024 || filter==null) throw new IllegalArgumentException("Invalid queue cursor"); + } + } + public record Page(List items,Cursor next,int unavailableItems) { + public Page { items=List.copyOf(items); } + } + private final MongoCollection projects; + public ModerationQueuePageReader(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(Cursor cursor,int limit) {return page(cursor,limit,Filter.ALL);} + public Page page(Cursor cursor,int limit,Filter filter) { + if(filter==null || cursor!=null && cursor.filter()!=filter)throw new IllegalArgumentException("Queue filter changed"); + if(limit<1 || limit>50)throw new IllegalArgumentException("Invalid queue page size"); + var stages=new ArrayList(); + var match=new Document("status",new Document("$in",List.of("PENDING","PUBLISHED"))) + .append("_id",new Document("$type",List.of("string","objectId"))); + var idBound=new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type","$_id"),"string")), + new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true)); + match.append("$expr",cursor==null?idBound:new Document("$and",List.of(idBound,new Document("$gte",List.of("$_id",literal(cursor.projectId())))))); + stages.add(new Document("$match",match));stages.add(new Document("$sort",new Document("_id",1))); + var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var pendingOrScanning=new Document("$filter",new Document("input",versions).append("as","v").append("cond",new Document("$or",List.of( + new Document("$eq",List.of("$$v.reviewStatus","PENDING")),new Document("$eq",List.of("$$v.scanResult.status","SCANNING")))))); + var projectOnly=new Document("$and",List.of(new Document("$eq",List.of("$status","PENDING")),new Document("$eq",List.of(new Document("$size",pendingOrScanning),0)))); + stages.add(new Document("$set",new Document("queueProjectOnly",projectOnly) + .append("queueVersion",new Document("$cond",List.of(projectOnly,Collections.singletonList(null),versions))))); + stages.add(new Document("$unwind",new Document("path","$queueVersion").append("includeArrayIndex","queueIndex").append("preserveNullAndEmptyArrays",true))); + stages.add(new Document("$match",new Document("$or",List.of(new Document("queueProjectOnly",true), + new Document("queueVersion.reviewStatus","PENDING").append("queueVersion.scanResult.status",new Document("$ne","SCANNING")))))); + if(cursor!=null)stages.add(new Document("$match",new Document("$expr",new Document("$or",List.of( + new Document("$gt",List.of("$_id",literal(cursor.projectId()))),new Document("$gt",List.of("$queueIndex",cursor.versionIndex()))))))); + if(filter==Filter.OPERATIONS)stages.add(new Document("$match",new Document("queueVersion.scanResult.status","FAILED"))); + if(filter==Filter.SECURITY)stages.add(new Document("$match",new Document("$or",List.of( + new Document("queueVersion.scanResult.status",new Document("$in",List.of("INFECTED","FLAGGED","SUSPICIOUS"))), + new Document("queueVersion.scanResult.verdict","BLOCK"), + new Document("queueVersion.scanResult.newIssueCount",new Document("$gt",0)), + new Document("queueVersion.scanResult.escalatedIssueCount",new Document("$gt",0)))))); + stages.add(new Document("$limit",limit+1)); + var projection=new Document("_id",1).append("queueIndex",1).append("queueProjectOnly",1) + .append("title",text("$title",256)).append("description",text("$description",1024)).append("author",text("$author",128)) + .append("imageUrl",text("$imageUrl",2048)).append("classification",text("$classification",32)).append("status",1).append("updatedAt",text("$updatedAt",64)) + .append("versionId",text("$queueVersion._id",129)).append("versionNumber",text("$queueVersion.versionNumber",128)) + .append("changelog",text("$queueVersion.changelog",1024)).append("scanStatus",text("$queueVersion.scanResult.status",32)) + .append("verdict",text("$queueVersion.scanResult.verdict",32)).append("scanState",text("$queueVersion.scanResult.scanState",64)); + for(String field:List.of("riskScore","knownIssueCount","newIssueCount","escalatedIssueCount"))projection.append(field,new Document("$convert",new Document("input","$queueVersion.scanResult."+field).append("to","int").append("onError",0).append("onNull",0))); + projection.append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","v") + .append("cond",new Document("$eq",List.of("$$v._id","$queueVersion._id")))))); + stages.add(new Document("$project",projection)); + var rows=new ArrayList(); + try(var iterator=projects.aggregate(stages).collation(Collation.builder().locale("simple").build()).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).iterator()) {while(iterator.hasNext())rows.add(iterator.next());} + boolean more=rows.size()>limit;if(more)rows.removeLast(); + var items=new ArrayList();int unavailable=0; + for(var row:rows) { + Object id=row.get("_id");String projectId=id instanceof ObjectId oid?oid.toHexString():(String)id; + boolean only=Boolean.TRUE.equals(row.get("queueProjectOnly"));String versionId=row.getString("versionId"); + if(!validId(projectId) || !only && (!validId(versionId) || row.getInteger("idCount",0)!=1)) {unavailable++;continue;} + var status=enumValue(ScanStatus.class,row.getString("scanStatus")); + if(row.getString("scanStatus")!=null && status==null) {unavailable++;continue;} + var scan=row.getString("scanStatus")==null?null:new AdminVerificationQueueScanDTO(status,row.getString("verdict"),row.getString("scanState"), + row.getInteger("riskScore",0),row.getInteger("knownIssueCount",0),row.getInteger("newIssueCount",0),row.getInteger("escalatedIssueCount",0)); + items.add(new AdminVerificationQueueItemDTO(projectId,row.getString("title"),row.getString("description"),row.getString("author"),row.getString("imageUrl"), + enumValue(ProjectClassification.class,row.getString("classification")),enumValue(ProjectStatus.class,row.getString("status")),row.getString("updatedAt"), + only?null:new AdminVerificationQueueVersionDTO(versionId,row.getString("versionNumber"),row.getString("changelog"),ProjectVersion.ReviewStatus.PENDING,scan))); + } + Cursor next=null; + if(more) {var last=rows.getLast();next=new Cursor(last.get("_id"),((Number)last.get("queueIndex")).longValue(),filter);} + return new Page(items,next,unavailable); + } + private static boolean validId(String s) {return s!=null && !s.isBlank() && s.codePointCount(0,s.length())<=128 && s.chars().noneMatch(Character::isISOControl);} + private static > T enumValue(Class type,String value) {try{return value==null?null:Enum.valueOf(type,value);}catch(IllegalArgumentException invalid){return null;}} + private static Document text(String field,int limit) {return new Document("$cond",Arrays.asList(new Document("$eq",List.of(new Document("$type",field),"string")),new Document("$substrCP",List.of(field,0,limit)),null));} + private static Document literal(Object value) {return new Document("$literal",value);} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationActivator.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationActivator.java new file mode 100644 index 000000000..2b783bb77 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationActivator.java @@ -0,0 +1,127 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.types.Binary; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Atomic admission only; the durable remote scheduler delivers the fixed request. */ +public final class ProjectMutationActivator { + public static final String ADMISSIONS="project_mutation_admissions"; + public record Result(String state,String afterSha256) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).readPreference(ReadPreference.primary()) + .writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + private final MongoTemplate mongo;private final ReviewRepairWorkflow budget;private final ProjectMutationAdmissionPreparation preparation; + private final ReviewSnapshotArchive archive;private final ReviewRepairJournal journal;private final RawReviewSnapshotReader reader; + public ProjectMutationActivator(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationAdmissionPreparation preparation,ReviewSnapshotArchive archive,ReviewRepairJournal journal) { + this.mongo=Objects.requireNonNull(mongo);this.budget=Objects.requireNonNull(budget);this.preparation=Objects.requireNonNull(preparation); + this.archive=Objects.requireNonNull(archive);this.journal=Objects.requireNonNull(journal);reader=new RawReviewSnapshotReader(mongo); + } + public Result activate(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + return budget.call(allowed->activateWithinBudget(prepared,actor,allowed),permitted); + } + Result activateWithinBudget(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier allowed) { + var previous=receiptWithinBudget(prepared,actor,allowed);if(!"UNKNOWN".equals(previous.state()))return previous; + preparation.verifyCurrent(prepared,actor,allowed);var source=archive.load(prepared.id());var projected=projected(source);String afterSha=digest(bytes(projected)); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw invalid(); + ensureAdmissionCollection(allowed); + var claim=journal.claim(new ReviewRepairPreparation.Prepared(source.id(),prepared.decisionSha256(),source.createdAt(),source.expiresAt()),actor,source.action(),allowed); + if(claim==null)return receiptWithinBudget(prepared,actor,allowed); + try { + for(int transactionAttempt=0;;transactionAttempt++){ + boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(allowed); + var operations=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION),session); + var op=operations.find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();if(op==null)throw invalid(); + var current=reader.capture(session,source.projectId(),source.versionIndex(),prepared.versionId()); + String state="NOT_APPLIED",after=null; + if(prepared.heldSha256().equals(current.sha256())) { + var query=new Document("_id",source.projectId()).append("status",new Document("$in",List.of("PENDING","PUBLISHED","UNLISTED","PRIVATE"))) + .append("$expr",new Document("$and",List.of(new Document("$eq",List.of(new Document("$type","$status"),"string")), + new Document("$gte",List.of("$$NOW",new Date(source.createdAt()))),new Document("$lt",List.of("$$NOW",new Date(source.expiresAt())))))); + permission(allowed); + if(ReviewRepairIo.collection(mongo.getCollection("projects"),session).updateOne(session,query,new Document("$set",new Document("versions."+source.versionIndex(),projected)),new UpdateOptions().collation(BINARY)).getModifiedCount()==1) { + var actual=reader.capture(session,source.projectId(),source.versionIndex(),prepared.versionId());if(!afterSha.equals(actual.sha256()))throw invalid(); + // One retained admission per fixed request also fences competing signed decisions. + ReviewRepairIo.collection(mongo.getCollection(ADMISSIONS),session).insertOne(session,admission(source,prepared,afterSha)); + state="APPLIED";after=afterSha; + } + } + permission(allowed); + var fields=new Document("state",state).append("afterSha256",after).append("mutationId",prepared.mutationId()).append("requestId",prepared.binding().requestId()).append("finishedAt","$$NOW"); + if(operations.updateOne(session,op,List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1)throw invalid(); + permission(allowed);commitAttempted=true;session.commitTransaction();return new Result(state,after); + }catch(RuntimeException failure){ + boolean aborted=false; + if(!commitAttempted)try{session.abortTransaction();aborted=true;}catch(RuntimeException ignored){} + if(!commitAttempted && aborted && transactionAttempt<2 && failure instanceof MongoException conflict + && conflict.getCode()==112 && conflict.hasErrorLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL)){ + permission(allowed);continue; + } + throw failure; + } + } + } + }catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + if(uncertain instanceof SecurityException){try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try{var result=receiptWithinBudget(prepared,actor,allowed);if(!"UNKNOWN".equals(result.state()))return result;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}return new Result("UNKNOWN",null); + } + } + } + private void ensureAdmissionCollection(BooleanSupplier allowed) { + permission(allowed); + var database=ReviewRepairIo.database(mongo.getDb().withReadPreference(ReadPreference.primary()) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true))); + // Avoid implicit namespace creation inside competing admission transactions. + if(database.listCollections().filter(new Document("name",ADMISSIONS)).first()==null) { + permission(allowed); + try{database.createCollection(ADMISSIONS);} + catch(MongoCommandException concurrent){if(concurrent.getErrorCode()!=48)throw concurrent;} + } + permission(allowed); + } + public Result receipt(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + return budget.call(allowed->receiptWithinBudget(prepared,actor,allowed),permitted); + } + Result receiptWithinBudget(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier allowed) { + permission(allowed);if(!prepared.equals(preparation.recoverWithinBudget(prepared.id(),actor,allowed)))throw invalid();var source=archive.load(prepared.id()); + var op=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)) + .find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(allowed); + if(op==null || !actor.equals(op.get("actor")) || !source.action().name().equals(op.get("action")) || !prepared.decisionSha256().equals(op.get("sha256")) + || !Long.valueOf(source.createdAt()).equals(op.get("createdAt")) || !Long.valueOf(source.expiresAt()).equals(op.get("expiresAt")) + || !prepared.mutationId().equals(op.get("mutationId")) || !prepared.binding().requestId().equals(op.get("requestId")) || !(op.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(op.get("state")) && op.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + String expected=digest(bytes(projected(source))); + if("APPLIED".equals(op.get("state")) && expected.equals(op.get("afterSha256"))) { + var record=ReviewRepairIo.collection(mongo.getCollection(ADMISSIONS).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)) + .find(new Document("_id",prepared.binding().requestId())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(record==null || !Arrays.equals(bytes(record),bytes(admission(source,prepared,expected))))throw invalid();permission(allowed);return new Result("APPLIED",expected); + } + return new Result("UNKNOWN",null); + } + static Document projected(ReviewSnapshotArchive.Snapshot source) { + var payload=decode(source.versionBytes());var held=decode(payload.get("heldVersion",Binary.class).getData());var scan=held.get("scanResult",Document.class); + scan.put("scanState","REMOTE_REVIEW");scan.put("scanTimestamp",source.createdAt());scan.put("remoteReview",payload.get("binding",Document.class));return held; + } + static Document admission(ReviewSnapshotArchive.Snapshot source,ProjectMutationAdmissionPreparation.Prepared prepared,String afterSha) { + return new Document("_id",prepared.binding().requestId()).append("decisionId",prepared.id()).append("decisionSha256",prepared.decisionSha256()) + .append("projectId",source.projectId()).append("versionId",prepared.versionId()).append("mutationId",prepared.mutationId()) + .append("actor",source.actorId()).append("heldSha256",prepared.heldSha256()).append("afterSha256",afterSha).append("state","ACTIVE"); + } + private static Document decode(byte[] bytes){return new RawBsonDocument(bytes).decode(new DocumentCodec());} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}} + private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Mutation activation is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Mutation activation changed or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionAttempts.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionAttempts.java new file mode 100644 index 000000000..7f3227f63 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionAttempts.java @@ -0,0 +1,103 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Durable scheduling bookkeeping only. Callers must authenticate candidates and outcome evidence. */ +public final class ProjectMutationAdmissionAttempts { + public static final String COLLECTION="project_mutation_admission_attempts"; + public enum Outcome { WAITING, YIELDED, ATTENTION, ADMITTED } + public record Scope(Object projectId,String versionId,String mutationId,String requestId,int scanAttempt) { + public Scope { + if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128) + || versionId==null || versionId.isBlank() || versionId.length()>128 || versionId.chars().anyMatch(Character::isISOControl) + || !uuid(mutationId) || !uuid(requestId) || scanAttempt<1)throw invalid(); + } + } + public record Claim(Scope scope,int sequence,String token,String decisionId,String heldSha256) {} + public record Status(String state,int attempts,Claim current,Outcome outcome,Long nextAttemptAt) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection records;private final ReviewRepairWorkflow budget;private final int maxAttempts;private final long cooldownMillis; + public ProjectMutationAdmissionAttempts(MongoTemplate mongo,ReviewRepairWorkflow budget){this(mongo,budget,256,60000);} + public ProjectMutationAdmissionAttempts(MongoTemplate mongo,ReviewRepairWorkflow budget,int maxAttempts,long cooldownMillis) { + if(maxAttempts<1 || maxAttempts>256 || cooldownMillis<100 || cooldownMillis>3600000)throw invalid();this.maxAttempts=maxAttempts;this.cooldownMillis=cooldownMillis;this.budget=Objects.requireNonNull(budget); + records=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public Claim begin(Scope scope,String heldSha256,BooleanSupplier permitted){return budget.call(allowed->beginWithinBudget(scope,heldSha256,allowed),permitted);} + Claim beginWithinBudget(Scope scope,String heldSha256,BooleanSupplier allowed) { + permission(allowed);if(scope==null || !sha(heldSha256))throw invalid();var stored=read(scope.requestId()); + if(stored==null) { + var initial=new Document("_id",scope.requestId()).append("scope",scope(scope)).append("state","WAITING").append("nextAttemptAt",new Date(0)).append("attempts",List.of()); + permission(allowed);try{ReviewRepairIo.collection(records).insertOne(initial);}catch(MongoException uncertain){/* Exact read-back below; creation itself never grants a claim. */} + stored=read(scope.requestId()); + } + var status=decode(scope,stored);if(!Set.of("WAITING","YIELDED").contains(status.state()) || status.attempts()>=maxAttempts)return null; + int sequence=status.attempts()+1;String token=UUID.randomUUID().toString(),decision=decision(scope,sequence); + var attempt=new Document("sequence",sequence).append("token",token).append("decisionId",decision).append("heldSha256",heldSha256); + var nextAttempt=new Document("$mergeObjects",List.of(literal(attempt),new Document("startedAt","$$NOW"))); + var query=exact(stored);query.append("$expr",new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",literal(stored))),new Document("$lte",List.of("$nextAttemptAt","$$NOW"))))); + permission(allowed); + try { + var result=ReviewRepairIo.collection(records).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","RUNNING") + .append("attempts",new Document("$concatArrays",List.of("$attempts",List.of(nextAttempt)))))),new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + if(result==null)return null;var found=decode(scope,result);permission(allowed);return token.equals(found.current().token())?found.current():null; + }catch(MongoException uncertain){var found=decode(scope,read(scope.requestId()));permission(allowed);return found.current()!=null && token.equals(found.current().token())?found.current():null;} + } + public Status status(Scope scope,BooleanSupplier permitted){return budget.call(allowed->statusWithinBudget(scope,allowed),permitted);} + Status statusWithinBudget(Scope scope,BooleanSupplier allowed){permission(allowed);var value=read(scope.requestId());var result=value==null?new Status("ABSENT",0,null,null,null):decode(scope,value);permission(allowed);return result;} + public Status finish(Claim claim,Outcome outcome,BooleanSupplier permitted){return budget.call(allowed->finishWithinBudget(claim,outcome,allowed),permitted);} + Status finishWithinBudget(Claim claim,Outcome outcome,BooleanSupplier allowed) { + permission(allowed);Objects.requireNonNull(outcome);var stored=read(claim.scope().requestId());var current=decode(claim.scope(),stored); + if(!claim.equals(current.current()))throw invalid();if(!"RUNNING".equals(current.state())){if(outcome!=current.outcome())throw invalid();return current;} + String state=continuation(outcome) && current.attempts()>=maxAttempts?"ATTENTION":outcome.name(); + var history=new ArrayList<>(stored.getList("attempts",Document.class));var last=history.removeLast(); + var completed=new Document("$mergeObjects",List.of(literal(last),new Document("outcome",outcome.name()).append("finishedAt","$$NOW"))); + var fields=new Document("state",state).append("nextAttemptAt",new Document("$add",List.of("$$NOW",outcome==Outcome.YIELDED?100L:cooldownMillis))) + .append("attempts",new Document("$concatArrays",List.of(literal(history),List.of(completed)))); + permission(allowed);try{ReviewRepairIo.collection(records).updateOne(exact(stored),List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY));} + catch(MongoException uncertain){/* A lost finish reply never starts another attempt. */} + var found=decode(claim.scope(),read(claim.scope().requestId()));permission(allowed); + if(!claim.equals(found.current()) || found.outcome()!=outcome)throw invalid();return found; + } + private Status decode(Scope scope,Document stored) { + if(stored==null || stored.size()!=5 || !scope.requestId().equals(stored.get("_id")) || !(stored.get("scope") instanceof Document raw) + || !Arrays.equals(bytes(raw),bytes(scope(scope))) || !(stored.get("nextAttemptAt") instanceof Date next))throw invalid(); + var history=stored.getList("attempts",Document.class);if(history==null || history.size()>256)throw invalid();String state=stored.getString("state"); + if(history.isEmpty()){if(!"WAITING".equals(state) || next.getTime()!=0)throw invalid();return new Status(state,0,null,null,0L);} + Claim claim=null;Outcome outcome=null;Date previous=null; + for(int i=0;i observations,boolean acknowledgeUncertainty) { + public Request { + if(observations==null || observations.size()>256)throw invalid();observations=Map.copyOf(observations); + } + } + public record Prepared(String id,String decisionSha256,String mutationId,String versionId,String heldSha256, + RemoteReviewBinding binding,String rule,long createdAt,long expiresAt) {} + private final MongoTemplate mongo;private final ReviewRepairWorkflow budget;private final ReviewSnapshotArchive archive; + private final ProjectMutationPriorWorkReader prior;private final ProjectMutationJobAccounting accounting; + private final ProjectMutationReferenceReader history;private final RawReviewSnapshotReader reader;private final RemoteReviewClient client; + private final Clock clock;private final long lifetimeMillis; + public ProjectMutationAdmissionPreparation(MongoTemplate mongo,ReviewRepairWorkflow budget,ReviewSnapshotArchive archive, + ProjectMutationReferenceReader history,ProjectMutationPriorWorkReader prior,ProjectMutationJobAccounting accounting, + RemoteReviewClient client,Clock clock,long lifetimeMillis) { + if(lifetimeMillis<1000 || lifetimeMillis>120000)throw invalid();this.mongo=Objects.requireNonNull(mongo);this.budget=Objects.requireNonNull(budget); + this.archive=Objects.requireNonNull(archive);this.history=Objects.requireNonNull(history);this.prior=Objects.requireNonNull(prior); + this.accounting=Objects.requireNonNull(accounting);this.client=Objects.requireNonNull(client);this.clock=Objects.requireNonNull(clock); + this.lifetimeMillis=lifetimeMillis;reader=new RawReviewSnapshotReader(mongo); + } + public Prepared prepare(Request request,BooleanSupplier permitted) {return budget.call(allowed->prepareWithinBudget(request,allowed),permitted);} + Prepared prepareWithinBudget(Request request,BooleanSupplier allowed) { + if(request==null || !uuid(request.id()) || !uuid(request.mutationId()) || !digestValue(request.heldSha256()) + || request.actor()==null || request.actor().isBlank() || request.actor().length()>256)throw invalid(); + permission(allowed);var captured=reader.capture(request.projectId(),request.versionIndex(),request.versionId()); + if(!captured.sha256().equals(request.heldSha256()))throw invalid();var held=decode(captured.versionBytes()); + var pointer=held.get("versionMutation",Document.class);var scan=held.get("scanResult",Document.class); + if(pointer==null || !request.mutationId().equals(pointer.get("operationId")) || scan==null + || !"MUTATION_HELD".equals(scan.get("scanState")) || !"SCANNING".equals(scan.get("status")) + || !"PENDING".equals(held.get("reviewStatus")) || !Boolean.FALSE.equals(scan.get("manualRescan")))throw invalid(); + history.requireHeldHeads(request.projectId(),bytes(new Document("_id",request.projectId()).append("versions",List.of(held))),allowed); + var inventory=prior.readWithinBudget(request.projectId(),request.mutationId(),allowed);var evidence=new ArrayList(); + var used=new HashSet();boolean uncertain=false; + try(var receipts=accounting.readBatch(request.projectId(),allowed)){ + for(var work:inventory.work()) { + permission(allowed);var item=new Document("mutationId",work.mutationId()).append("versionId",work.versionId()).append("beforeSha256",work.beforeSha256()) + .append("kind",work.kind().name()).append("reason",work.reason()); + if(work.kind()==ProjectMutationPriorWorkReader.Kind.REMOTE_JOB) { + String key=work.mutationId()+"/"+work.versionId(),id=request.observations().get(key);if(!uuid(id))throw invalid();used.add(key); + var observation=receipts.receipt(id,request.projectId(),work.mutationId(),work.versionId()); + if(!Set.of("OBSERVED","UNKNOWN").contains(observation.state()))throw invalid(); + ReviewOrphanCancellationJournal.validateObservation(observation.observation(),work.binding()); + var status=observation.observation().status();boolean completed="OBSERVED".equals(observation.state()) + && "REMOTE_STATUS".equals(observation.observation().kind()) && status!=null + && "COMPLETED".equals(status.state()) && "COMPLETED".equals(status.workState()); + uncertain|=!completed;item.append("observation",new Document("id",id).append("state",observation.state()) + .append("receivedAt",observation.receivedAt()).append("body",ReviewOrphanCancellationJournal.observationDocument(observation.observation()))); + } else if(work.kind()==ProjectMutationPriorWorkReader.Kind.UNRESOLVED)uncertain=true; + evidence.add(item); + } + } + if(!used.equals(request.observations().keySet()) || uncertain && !request.acknowledgeUncertainty())throw invalid(); + var configuration=client.configuration(allowed,ReviewRepairIo::currentRemainingNanos);permission(allowed); + var version=mongo.getConverter().read(ProjectVersion.class,held); + String context=ArtifactReviewContext.automaticallyReviewableFingerprint(version); + if(context==null || !(scan.get("scanAttempt") instanceof Integer || scan.get("scanAttempt") instanceof Long))throw invalid(); + long attempt=((Number)scan.get("scanAttempt")).longValue();if(attempt<1 || attempt>Integer.MAX_VALUE)throw invalid(); + var binding=new RemoteReviewBinding(request.projectId().toString(),request.versionId(),scan.getString("scanRequestId"),(int)attempt, + held.getString("fileUrl"),held.getString("hash"),context,configuration.policyVersion(),configuration.reviewConfigSha256(),null,false,configuration.origin()); + var rawBinding=new Document();mongo.getConverter().write(binding,rawBinding); + String rule=uncertain?"ACKNOWLEDGED_UNCERTAINTY":"PRIOR_WORK_ACCOUNTED"; + var observationIds=new Document();request.observations().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(e->observationIds.append(e.getKey(),e.getValue())); + var payload=new Document("schema",1).append("mutationId",request.mutationId()).append("versionId",request.versionId()).append("heldSha256",captured.sha256()) + .append("heldVersion",new Binary(captured.versionBytes())).append("binding",rawBinding).append("groups",inventory.groups()) + .append("evidence",evidence).append("observations",observationIds).append("acknowledgedUncertainty",request.acknowledgeUncertainty()).append("rule",rule); + var existing=archive.find(request.id());long created=existing==null?clock.millis():existing.createdAt(),expires=existing==null?Math.addExact(created,lifetimeMillis):existing.expiresAt();live(created,expires); + var candidate=new ReviewSnapshotArchive.Snapshot(request.id(),request.projectId(),request.versionIndex(),request.actor(),ReviewSnapshotArchive.Action.PROJECT_MUTATION_ADMISSION,created,expires,bytes(payload)); + permission(allowed); + try{archive.retain(candidate);}catch(RuntimeException uncertainWrite){var found=archive.find(candidate.id());if(found==null || !same(candidate,found))throw uncertainWrite;} + permission(allowed);if(!reader.isCurrent(captured))throw invalid(); + var result=recoverWithinBudget(request.id(),request.actor(),allowed);live(result.createdAt(),result.expiresAt());return result; + } + public Prepared recover(String id,String actor,BooleanSupplier permitted) {return budget.call(allowed->recoverWithinBudget(id,actor,allowed),permitted);} + Prepared recoverWithinBudget(String id,String actor,BooleanSupplier allowed) { + permission(allowed);var stored=archive.load(id); + if(!stored.actorId().equals(actor))throw invalid(); + var result=decodeStored(mongo,stored);permission(allowed);return result; + } + static Prepared decodeStored(MongoTemplate mongo,ReviewSnapshotArchive.Snapshot stored) { + if(stored.action()!=ReviewSnapshotArchive.Action.PROJECT_MUTATION_ADMISSION || stored.expiresAt()-stored.createdAt()>120000)throw invalid(); + var payload=decode(stored.versionBytes()); + if(payload.size()!=11 || !Integer.valueOf(1).equals(payload.get("schema")) || !uuid(payload.getString("mutationId")) + || !(payload.get("heldVersion") instanceof Binary original) || !digest(original.getData()).equals(payload.get("heldSha256")) + || !(payload.get("acknowledgedUncertainty") instanceof Boolean ack) + || !Set.of("PRIOR_WORK_ACCOUNTED","ACKNOWLEDGED_UNCERTAINTY").contains(Objects.toString(payload.get("rule"),"")) + || "ACKNOWLEDGED_UNCERTAINTY".equals(payload.get("rule")) && !ack)throw invalid(); + var held=decode(original.getData());var binding=mongo.getConverter().read(RemoteReviewBinding.class,payload.get("binding",Document.class)); + var scan=held.get("scanResult",Document.class); + if(binding.jobId()!=null || binding.origin()==null || binding.manualRescan() || !binding.projectId().equals(stored.projectId().toString()) + || !binding.versionId().equals(payload.get("versionId")) || !binding.versionId().equals(held.get("_id")) + || !binding.requestId().equals(scan.get("scanRequestId")) || binding.attempt()!=((Number)scan.get("scanAttempt")).longValue() + || !binding.filePath().equals(held.get("fileUrl")) || !binding.artifactSha256().equals(held.get("hash")) + || !binding.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(mongo.getConverter().read(ProjectVersion.class,held))))throw invalid(); + return new Prepared(stored.id(),digest(stored.versionBytes()),payload.getString("mutationId"),payload.getString("versionId"),payload.getString("heldSha256"),binding,payload.getString("rule"),stored.createdAt(),stored.expiresAt()); + } + Prepared verifyCurrent(Prepared expected,String actor,BooleanSupplier allowed) { + if(!expected.equals(recoverWithinBudget(expected.id(),actor,allowed)))throw invalid(); + var stored=archive.load(expected.id());var payload=decode(stored.versionBytes());var ids=new HashMap(); + payload.get("observations",Document.class).forEach((key,value)->{if(!(value instanceof String id))throw invalid();ids.put(key,id);}); + var request=new Request(expected.id(),stored.projectId(),stored.versionIndex(),expected.versionId(),expected.heldSha256(),expected.mutationId(),actor,ids,payload.getBoolean("acknowledgedUncertainty")); + var found=prepareWithinBudget(request,allowed);if(!expected.equals(found))throw invalid();return found; + } + private void live(long created,long expires){long now=clock.millis();if(now=expires)throw invalid();} + private static boolean same(ReviewSnapshotArchive.Snapshot a,ReviewSnapshotArchive.Snapshot b){return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() && a.actorId().equals(b.actorId()) && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes());} + private static Document decode(byte[] value){return new RawBsonDocument(value).decode(new DocumentCodec());} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}} + private static boolean digestValue(String value){return value!=null && value.matches("[0-9a-f]{64}");} + private static boolean uuid(String value){return value!=null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Mutation admission is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Mutation admission evidence changed or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionReader.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionReader.java new file mode 100644 index 000000000..1f9887510 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionReader.java @@ -0,0 +1,67 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.RemoteReviewBinding; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Authenticates admission provenance, not the truth or approval authority of later scan outcomes. */ +public final class ProjectMutationAdmissionReader { + public record History(ReviewSnapshotArchive.Snapshot source,ProjectMutationAdmissionPreparation.Prepared decision) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final Set OUTCOME=Set.of("scanResult","retainedRemoteReview","reviewStatus","rejectionReason","scheduledPublishDate", + "securityApprovedAt","approvedSecurityEvidence","approvedSecurityContextSha256","approvedReviewOrigins","approvedFindingReviewHead","securityApprovalProjectId","approvedIssueBaselines"); + private final MongoTemplate mongo;private final ReviewSnapshotArchive archive;private final ReviewRemoteTargetReader targets; + public ProjectMutationAdmissionReader(MongoTemplate mongo,ReviewSnapshotArchive archive){this.mongo=Objects.requireNonNull(mongo);this.archive=Objects.requireNonNull(archive);targets=new ReviewRemoteTargetReader(mongo);} + public History read(Object projectId,String requestId,BooleanSupplier allowed) { + permission(allowed);if(requestId==null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid(); + var record=read(ProjectMutationActivator.ADMISSIONS,requestId);if(record==null || !projectId.equals(record.get("projectId")) || !(record.get("decisionId") instanceof String id))throw invalid(); + var source=archive.load(id);var prepared=ProjectMutationAdmissionPreparation.decodeStored(mongo,source); + if(!projectId.equals(source.projectId()) || !requestId.equals(prepared.binding().requestId()))throw invalid(); + String after=digest(bytes(ProjectMutationActivator.projected(source))); + if(!Arrays.equals(bytes(record),bytes(ProjectMutationActivator.admission(source,prepared,after))))throw invalid(); + var op=read(ReviewRepairJournal.COLLECTION,id); + if(op==null || !"APPLIED".equals(op.get("state")) || !source.actorId().equals(op.get("actor")) || !source.action().name().equals(op.get("action")) + || !prepared.decisionSha256().equals(op.get("sha256")) || !Long.valueOf(source.createdAt()).equals(op.get("createdAt")) + || !Long.valueOf(source.expiresAt()).equals(op.get("expiresAt")) || !prepared.mutationId().equals(op.get("mutationId")) + || !requestId.equals(op.get("requestId")) || !after.equals(op.get("afterSha256")) || !(op.get("finishedAt") instanceof Date))throw invalid(); + permission(allowed);return new History(source,prepared); + } + public void requireNoAdmission(Document version,BooleanSupplier allowed) { + var scan=version.get("scanResult") instanceof Document doc?doc:null; + var retained=version.get("retainedRemoteReview") instanceof Document doc?doc:null; + var remote=scan!=null && scan.get("remoteReview") instanceof Document doc?doc:null; + for(var source:Arrays.asList(scan,remote,retained)) { + if(source==null)continue;String field=source==scan?"scanRequestId":"requestId"; + if(source.get(field) instanceof String request)requireUnadmitted(request,allowed); + } + } + public void requireUnadmitted(String requestId,BooleanSupplier allowed) { + permission(allowed);if(read(ProjectMutationActivator.ADMISSIONS,requestId)!=null)throw invalid();permission(allowed); + } + public void requireHead(Object projectId,Document current,BooleanSupplier allowed) { + permission(allowed);var pointer=current.get("versionMutation",Document.class); + if(pointer==null || pointer.size()!=3 || !(pointer.get("requestId") instanceof String request))throw invalid(); + var history=read(projectId,request,allowed);var expected=ProjectMutationActivator.projected(history.source()); + if(!history.decision().mutationId().equals(pointer.get("operationId")) || !history.decision().versionId().equals(current.get("_id")))throw invalid(); + var left=new Document(expected);var right=new Document(current);for(var field:OUTCOME){left.remove(field);right.remove(field);} + if(VersionReviewTransition.classify(List.of(left),List.of(right)).getFirst().changes().stream().anyMatch(c->c!=VersionReviewTransition.Change.METADATA))throw invalid(); + // Validate the original artifact/context/attempt/origin even before a remote job ID has been attached. + var copy=new RawBsonDocument(bytes(current)).decode(new DocumentCodec());var scan=copy.get("scanResult",Document.class); + var remote=scan==null?copy.get("retainedRemoteReview",Document.class):scan.get("remoteReview",Document.class);if(remote==null)throw invalid(); + String job=remote.get("jobId")==null?"00000000-0000-0000-0000-000000000000":remote.getString("jobId");remote.put("jobId",job); + RemoteReviewBinding binding=targets.validate(projectId,copy); + if(!history.decision().binding().withJobId(job).equals(binding))throw invalid();permission(allowed); + } + private Document read(String collection,String id){return ReviewRepairIo.collection(mongo.getCollection(collection).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)) + .find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}} + private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Mutation admission history is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Mutation admission history changed or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionScheduler.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionScheduler.java new file mode 100644 index 000000000..5645a1664 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionScheduler.java @@ -0,0 +1,18 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.BoundedReviewScheduler; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; + +public final class ProjectMutationAdmissionScheduler extends BoundedReviewScheduler { + private final AtomicLong unavailable; + public ProjectMutationAdmissionScheduler(ProjectMutationDiscovery discovery,ProjectMutationAutomaticAdmission automatic,Settings settings) { + this(discovery,automatic,settings,new AtomicLong()); + } + private ProjectMutationAdmissionScheduler(ProjectMutationDiscovery discovery,ProjectMutationAutomaticAdmission automatic,Settings settings,AtomicLong unavailable) { + super("mutation-admission",(cursor,limit)->{var page=discovery.page(cursor,limit);unavailable.addAndGet(page.unavailable());return new Page<>(page.candidates(),page.next());}, + (candidate,running)->automatic.advance(candidate,running).state(),settings); + Objects.requireNonNull(discovery);Objects.requireNonNull(automatic);this.unavailable=unavailable; + } + public long unavailableCandidates(){return unavailable.get();} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerConfiguration.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerConfiguration.java new file mode 100644 index 000000000..15d06c8cc --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerConfiguration.java @@ -0,0 +1,33 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewScheduler; +import org.springframework.context.annotation.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="app.warden.repair.admission.scheduler.enabled",havingValue="true") +public class ProjectMutationAdmissionSchedulerConfiguration { + @Bean(destroyMethod="close") + ProjectMutationRecoveryScheduler projectMutationRecoveryScheduler(ProjectMutationAttemptRecovery recovery,ProjectMutationAdmissionScheduler admission, + @Value("${app.warden.repair.admission.scheduler.page-size:16}") int pageSize, + @Value("${app.warden.repair.admission.scheduler.poll-millis:1000}") long poll, + @Value("${app.warden.repair.admission.scheduler.drain-millis:10000}") long drain) { + java.util.Objects.requireNonNull(admission); + return new ProjectMutationRecoveryScheduler(recovery,new ProjectMutationRecoveryScheduler.Settings(1,pageSize,poll,drain)); + } + + @Bean(destroyMethod="close") + ProjectMutationAdmissionScheduler projectMutationAdmissionScheduler(ProjectMutationDiscovery discovery,ProjectMutationAutomaticAdmission automatic, + RemoteReviewScheduler delivery, + @Value("${app.warden.repair.enabled:false}") boolean repair, + @Value("${app.warden.jobs.enabled:false}") boolean jobs, + @Value("${app.warden.repair.admission.scheduler.workers:1}") int workers, + @Value("${app.warden.repair.admission.scheduler.page-size:16}") int pageSize, + @Value("${app.warden.repair.admission.scheduler.poll-millis:1000}") long poll, + @Value("${app.warden.repair.admission.scheduler.drain-millis:10000}") long drain) { + if(!repair || !jobs)throw new IllegalStateException("Automatic admission requires repair and remote review enabled"); + java.util.Objects.requireNonNull(delivery); + return new ProjectMutationAdmissionScheduler(discovery,automatic,new ProjectMutationAdmissionScheduler.Settings(workers,pageSize,poll,drain)); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAttemptRecovery.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAttemptRecovery.java new file mode 100644 index 000000000..611921218 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAttemptRecovery.java @@ -0,0 +1,56 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Finds interrupted bookkeeping and reconciles only an already committed, authenticated admission. */ +public final class ProjectMutationAttemptRecovery { + public static final String INDEX="mutation_attempt_state_id"; + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + public record Page(List candidates,String next,int examined,int unavailable) {public Page{candidates=List.copyOf(candidates);}} + private final MongoCollection records;private final ReviewRepairWorkflow budget;private final ProjectMutationAdmissionAttempts attempts;private final ProjectMutationAdmissionReader admissions; + public ProjectMutationAttemptRecovery(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationAdmissionAttempts attempts,ReviewSnapshotArchive archive) { + this.budget=Objects.requireNonNull(budget);this.attempts=Objects.requireNonNull(attempts);admissions=new ProjectMutationAdmissionReader(mongo,archive); + records=mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public void initialize(){records.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).createIndex(new Document("state",1).append("_id",1),new IndexOptions().name(INDEX).collation(BINARY));} + public Page page(String cursor,int limit) { + if(limit<1 || limit>64 || cursor!=null && !cursor.matches(UUID))throw invalid(); + var ids=new Document("$type","string").append("$regex","^"+UUID+"$");if(cursor!=null)ids.append("$gt",cursor); + var match=new Document("state","RUNNING").append("_id",ids).append("$expr",new Document("$eq",List.of(new Document("$type","$state"),"string"))); + var projection=new Document("_id",1).append("projectId",new Document("$cond",Arrays.asList(new Document("$eq",List.of(new Document("$type","$scope.projectId"),"objectId")),"$scope.projectId",safeString("$scope.projectId",128)))) + .append("versionId",safeString("$scope.versionId",128)).append("mutationId",safeString("$scope.mutationId",36)).append("requestId",safeString("$scope.requestId",36)) + .append("scopeSize",new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type","$scope"),"object")),new Document("$size",new Document("$objectToArray","$scope")),-1))) + .append("attempt",new Document("$cond",Arrays.asList(new Document("$in",List.of(new Document("$type","$scope.scanAttempt"),List.of("int","long"))),"$scope.scanAttempt",null))); + var rows=ReviewRepairIo.collection(records).aggregate(List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",limit+1),new Document("$project",projection))) + .hintString(INDEX).collation(BINARY).allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).into(new ArrayList<>()); + var candidates=new ArrayList();int unavailable=0,count=Math.min(limit,rows.size()); + for(var row:rows.subList(0,count)) { + try { + if(!Integer.valueOf(5).equals(row.get("scopeSize")) || !row.get("_id").equals(row.get("requestId")) || !(row.get("attempt") instanceof Number number) || number.longValue()<1 || number.longValue()>Integer.MAX_VALUE)throw invalid(); + candidates.add(new ProjectMutationAdmissionAttempts.Scope(row.get("projectId"),row.getString("versionId"),row.getString("mutationId"),row.getString("requestId"),((Number)row.get("attempt")).intValue())); + }catch(IllegalStateException | ClassCastException malformed){unavailable++;} + } + return new Page(candidates,rows.size()>limit?rows.get(count-1).getString("_id"):null,count,unavailable); + } + public String recover(ProjectMutationAdmissionAttempts.Scope scope,BooleanSupplier running) { + return budget.call(allowed->{ + var status=attempts.statusWithinBudget(scope,allowed);if(!"RUNNING".equals(status.state()))return status.state(); + var claim=status.current();ProjectMutationAdmissionReader.History proof; + try{proof=admissions.read(scope.projectId(),scope.requestId(),allowed);}catch(IllegalStateException unavailable){return "UNRESOLVED";} + var decision=proof.decision(); + if(!ProjectMutationAutomaticAdmission.ACTOR.equals(proof.source().actorId()) || !claim.decisionId().equals(decision.id()) || !claim.heldSha256().equals(decision.heldSha256()) + || !scope.mutationId().equals(decision.mutationId()) || !scope.versionId().equals(decision.versionId()) || scope.scanAttempt()!=decision.binding().attempt())return "UNRESOLVED"; + return attempts.finishWithinBudget(claim,ProjectMutationAdmissionAttempts.Outcome.ADMITTED,allowed).state(); + },running); + } + private static Document safeString(String field,int max) {return new Document("$cond",Arrays.asList(new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type",field),"string")),new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP",field),0)),new Document("$lte",List.of(new Document("$strLenCP",field),max)))),false)),field,null));} + private static IllegalStateException invalid(){return new IllegalStateException("Invalid automatic admission recovery scope");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAutomaticAdmission.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAutomaticAdmission.java new file mode 100644 index 000000000..8a5e43dba --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAutomaticAdmission.java @@ -0,0 +1,98 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Automatic coordination never acknowledges uncertain duplicate execution or grants clearance. */ +public final class ProjectMutationAutomaticAdmission { + public static final String ACTOR="security-admission"; + public record Result(String state,String decisionId) {} + private final MongoTemplate mongo;private final ReviewRepairWorkflow budget;private final RawReviewSnapshotReader reader; + private final ProjectMutationReferenceReader history;private final ProjectMutationPriorWorkReader prior; + private final ProjectMutationObservationProgress progress; + private final ProjectMutationAdmissionAttempts attempts;private final ProjectMutationJobAccounting accounting; + private final ProjectMutationAdmissionPreparation preparation;private final ProjectMutationActivator activator;private final ProjectMutationAdmissionReader admissions; + public ProjectMutationAutomaticAdmission(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationReferenceReader history,ProjectMutationPriorWorkReader prior, + ProjectMutationAdmissionAttempts attempts,ProjectMutationJobAccounting accounting,ProjectMutationAdmissionPreparation preparation,ProjectMutationActivator activator,ReviewSnapshotArchive archive) { + this.mongo=Objects.requireNonNull(mongo);this.budget=Objects.requireNonNull(budget);this.history=Objects.requireNonNull(history);this.prior=Objects.requireNonNull(prior); + this.attempts=Objects.requireNonNull(attempts);this.accounting=Objects.requireNonNull(accounting);this.preparation=Objects.requireNonNull(preparation);this.activator=Objects.requireNonNull(activator); + progress=new ProjectMutationObservationProgress(mongo,accounting);reader=new RawReviewSnapshotReader(mongo);admissions=new ProjectMutationAdmissionReader(mongo,archive); + } + public Result advance(ProjectMutationDiscovery.Candidate candidate,BooleanSupplier running) { + return budget.call(allowed->advanceWithinBudget(candidate,()->allowed.getAsBoolean() && activeProject(candidate.projectId())),running); + } + private Result advanceWithinBudget(ProjectMutationDiscovery.Candidate candidate,BooleanSupplier allowed) { + permission(allowed);var scope=new ProjectMutationAdmissionAttempts.Scope(candidate.projectId(),candidate.versionId(),candidate.mutationId(),candidate.requestId(),candidate.attempt()); + var captured=reader.capture(candidate.projectId(),candidate.versionIndex(),candidate.versionId());var version=new RawBsonDocument(captured.versionBytes()).decode(new DocumentCodec()); + var pointer=version.get("versionMutation",Document.class);var scan=version.get("scanResult",Document.class); + if(pointer==null || !candidate.mutationId().equals(pointer.get("operationId")) || !candidate.requestId().equals(pointer.get("requestId")))return new Result("NO_WORK",null); + if(scan==null || !"MUTATION_HELD".equals(scan.get("scanState")))return reconcileAdmission(scope,version,allowed); + if(!candidate.requestId().equals(scan.get("scanRequestId")) || !(scan.get("scanAttempt") instanceof Integer || scan.get("scanAttempt") instanceof Long) + || ((Number)scan.get("scanAttempt")).longValue()!=candidate.attempt())return new Result("NO_WORK",null); + history.requireHeldHeads(candidate.projectId(),bytes(new Document("_id",candidate.projectId()).append("versions",List.of(version))),allowed); + var claim=attempts.beginWithinBudget(scope,captured.sha256(),allowed); + if(claim==null){var state=attempts.statusWithinBudget(scope,allowed);return new Result(state.state(),state.current()==null?null:state.current().decisionId());} + ProjectMutationPriorWorkReader.Inventory inventory; + try{inventory=prior.readWithinBudget(candidate.projectId(),candidate.mutationId(),allowed);} + catch(ProjectMutationPriorWorkReader.LimitExceeded limit){return finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,allowed);} + if(inventory.work().stream().anyMatch(work->work.kind()==ProjectMutationPriorWorkReader.Kind.UNRESOLVED))return finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,allowed); + var observations=new LinkedHashMap();boolean waiting=false,attention=false,yielded=false,madeProgress=false;int reads=0; + try(var receipts=accounting.readBatch(candidate.projectId(),allowed)){ + for(var work:inventory.work()) { + if(work.kind()!=ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)continue; + String key=work.mutationId()+"/"+work.versionId();String id=UUID.nameUUIDFromBytes(("automatic-status-1:"+claim.decisionId()+":"+key).getBytes(StandardCharsets.UTF_8)).toString(); + var receipt=progress.completed(scope,work,allowed,receipts); + if(receipt==null) { + // Leave time to retain the result and finish bookkeeping before yielding this generation. + if(reads>=1 || ReviewRepairIo.currentRemainingNanos()ReviewSnapshotArchive.MAX_BYTES || afterProject && offset!=0)throw invalid();} + } + public record Candidate(Object projectId,int versionIndex,String versionId,String mutationId,String requestId,int attempt) {} + public record Page(List candidates,Cursor next,int examined,int unavailable) { + public Page {candidates=List.copyOf(candidates);} + } + private final MongoCollection projects; + public ProjectMutationDiscovery(MongoTemplate mongo) { + projects=mongo.getCollection("projects").withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(Cursor cursor,int limit) { + if(limit<1 || limit>64)throw invalid(); + var terms=new ArrayList();terms.add(boundedString("$_id",128,true));terms.add(new Document("$eq",List.of(new Document("$type","$status"),"string"))); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))).append("status",new Document("$in",List.of("PENDING","PUBLISHED","UNLISTED","PRIVATE"))) + .append("versions",new Document("$elemMatch",new Document("scanResult.scanState","MUTATION_HELD"))).append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(new Document("$eq",List.of("$_id",literal(cursor.projectId()))),cursor.offset(),0)); + var array=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var view=new Document("held",new Document("$eq",List.of("$$v.scanResult.scanState","MUTATION_HELD"))) + .append("pending",new Document("$and",List.of(new Document("$eq",List.of("$$v.reviewStatus","PENDING")),new Document("$eq",List.of("$$v.scanResult.status","SCANNING")),new Document("$eq",List.of("$$v.scanResult.manualRescan",false))))) + .append("versionId",safeString("$$v._id",128)).append("mutationId",safeString("$$v.versionMutation.operationId",36)) + .append("requestId",safeString("$$v.scanResult.scanRequestId",36)).append("pointerRequest",safeString("$$v.versionMutation.requestId",36)) + .append("beforeSha256",safeString("$$v.versionMutation.beforeSha256",64)) + .append("pointerSize",new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type","$$v.versionMutation"),"object")),new Document("$size",new Document("$objectToArray","$$v.versionMutation")),-1))) + .append("attempt",new Document("$cond",Arrays.asList(new Document("$in",List.of(new Document("$type","$$v.scanResult.scanAttempt"),List.of("int","long"))),"$$v.scanResult.scanAttempt",null))); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",array)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(array,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=ReviewRepairIo.collection(projects).aggregate(pipeline).collation(Collation.builder().locale("simple").build()).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()){root=rows.hasNext()?rows.next():null;} + if(root==null)return new Page(List.of(),null,0,0); + Object id=root.get("_id");project(id);int start=root.getInteger("offset");var items=root.getList("items",Document.class);var candidates=new ArrayList();int unavailable=0; + for(int i=0;i128 || !Boolean.TRUE.equals(item.get("pending")) || !Integer.valueOf(3).equals(item.get("pointerSize")) + || version==null || version.isBlank() || version.length()>128 || version.chars().anyMatch(Character::isISOControl) + || mutation==null || !mutation.matches(UUID) || request==null || !request.matches(UUID) || !request.equals(item.get("pointerRequest")) + || sha==null || !sha.matches("[0-9a-f]{64}") || !(attempt instanceof Number number) || number.longValue()<1 || number.longValue()>Integer.MAX_VALUE) {unavailable++;continue;} + candidates.add(new Candidate(id,Math.addExact(start,i),version,mutation,request,((Number)attempt).intValue())); + } + int end=Math.addExact(start,items.size());var next=end NEW_FIELDS=Set.of("_id","_class","versionNumber","gameVersions","fileUrl","overrideFileUrl","hash","manifestId","manifestVersion", + "curseForgeFingerprint","downloadCount","releaseDate","changelog","dependencies","incompatibleProjectIds","channel","modpackConfigs", + "scanResult","reviewStatus","rejectionReason","scheduledPublishDate","securityApprovedAt","retainedRemoteReview","versionMutation","reviewReplacement", + "reviewIsolation","replacementSecurityHold","findingReviewHead","approvedFindingReviewHead","approvedSecurityEvidence","approvedReviewOrigins", + "approvedIssueBaselines","securityApprovalProjectId","approvedSecurityContextSha256"); + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).readPreference(ReadPreference.primary()) + .writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + private final MongoTemplate mongo;private final ProjectMutationPreparation preparation;private final ReviewSnapshotArchive archive;private final ReviewRepairJournal journal; + public ProjectMutationExecutor(MongoTemplate mongo,ProjectMutationPreparation preparation,ReviewSnapshotArchive archive,ReviewRepairJournal journal) { + this.mongo=Objects.requireNonNull(mongo);this.preparation=Objects.requireNonNull(preparation);this.archive=Objects.requireNonNull(archive);this.journal=Objects.requireNonNull(journal); + } + public Result apply(ProjectMutationPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var recovered=preparation.recover(prepared.id(),actor,permitted);if(!prepared.equals(recovered.prepared()))throw invalid(); + var previous=receipt(prepared,actor,permitted);if(!"UNKNOWN".equals(previous.state()))return previous; + var before=decode(recovered.before().versionBytes());var proposed=decode(recovered.after().versionBytes()); + var after=project(recovered,before,proposed);String afterSha=digest(bytes(after)); + var intent=archive.load(prepared.id()); + var applied=new ReviewSnapshotArchive.Snapshot(appliedArchiveId(prepared.id()),intent.projectId(),0,actor, + ReviewSnapshotArchive.Action.PROJECT_MUTATION_APPLIED,prepared.createdAt(),prepared.expiresAt(),bytes(after)); + permission(permitted); + try{archive.retain(applied);}catch(RuntimeException uncertain){var found=archive.find(applied.id());if(found==null || !same(applied,found))throw uncertain;} + permission(permitted); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw invalid(); + var claim=journal.claim(new ReviewRepairPreparation.Prepared(intent.id(),digest(intent.versionBytes()),intent.createdAt(),intent.expiresAt()),actor,ReviewSnapshotArchive.Action.PROJECT_MUTATION_INTENT,permitted); + if(claim==null)return receipt(prepared,actor,permitted);boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(permitted); + var operations=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION),session); + var op=operations.find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();if(op==null)throw invalid(); + var projects=ReviewRepairIo.collection(mongo.getCollection("projects"),session); + var query=new Document("_id",intent.projectId()).append("$expr",new Document("$and",List.of( + new Document("$eq",List.of("$$ROOT",new Document("$literal",before))),new Document("$gte",List.of("$$NOW",new Date(intent.createdAt()))), + new Document("$lt",List.of("$$NOW",new Date(intent.expiresAt())))))); + var rawCurrent=ReviewRepairIo.collection(mongo.getCollection("projects").withDocumentClass(RawBsonDocument.class),session) + .find(session,new Document("_id",intent.projectId())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + boolean exact=false; + if(rawCurrent!=null){var buffer=rawCurrent.getByteBuffer().asNIO();var currentBytes=new byte[buffer.remaining()];buffer.get(currentBytes);exact=Arrays.equals(currentBytes,recovered.before().versionBytes());} + permission(permitted);String state="NOT_APPLIED",appliedSha=null; + if(exact && projects.replaceOne(session,query,after,new ReplaceOptions().collation(BINARY)).getModifiedCount()==1) { + var changes=new ArrayList();for(var transition:recovered.transitions()) { + var version=transition.afterIndex()<0?null:after.getList("versions",Document.class).get(transition.afterIndex()); + String versionSha=version==null?null:digest(bytes(version)); + if(Objects.equals(versionSha,transition.beforeSha256()) && transition.beforeIndex()==transition.afterIndex())continue; + var scan=version==null?null:version.get("scanResult",Document.class); + String versionState=version==null?"REMOVED":scan!=null && "MUTATION_HELD".equals(scan.get("scanState"))?"HELD":"RETAINED"; + changes.add(new Document("versionId",transition.versionId()).append("beforeIndex",transition.beforeIndex()).append("afterIndex",transition.afterIndex()) + .append("beforeSha256",transition.beforeSha256()).append("afterSha256",versionSha).append("state",versionState)); + } + ReviewRepairIo.collection(mongo.getCollection(REFERENCES),session).insertOne(session,new Document("_id",prepared.id()).append("projectId",intent.projectId()) + .append("actor",actor).append("beforeArchiveId",prepared.beforeArchiveId()).append("beforeSha256",prepared.beforeSha256()) + .append("afterArchiveId",applied.id()).append("afterSha256",afterSha).append("versions",changes)); + state="APPLIED";appliedSha=afterSha; + } + if(operations.updateOne(session,op,List.of(new Document("$set",new Document("state",state).append("afterSha256",appliedSha).append("afterArchiveId",appliedSha==null?null:applied.id()).append("finishedAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1)throw invalid(); + permission(permitted);commitAttempted=true;session.commitTransaction();return new Result(state,appliedSha); + } catch(RuntimeException failure){if(!commitAttempted)try{session.abortTransaction();}catch(RuntimeException ignored){}throw failure;} + } catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + if(uncertain instanceof SecurityException){try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try{var observed=receipt(prepared,actor,permitted);if(!"UNKNOWN".equals(observed.state()))return observed;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}return new Result("UNKNOWN",null); + } + } + } + public Result receipt(ProjectMutationPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);if(!prepared.equals(preparation.recover(prepared.id(),actor,permitted).prepared()))throw invalid();var source=archive.load(prepared.id()); + var op=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)) + .find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(permitted); + if(op==null || !actor.equals(op.get("actor")) || !source.action().name().equals(op.get("action")) || !digest(source.versionBytes()).equals(op.get("sha256")) + || !Long.valueOf(source.createdAt()).equals(op.get("createdAt")) || !Long.valueOf(source.expiresAt()).equals(op.get("expiresAt")) || !(op.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(op.get("state")) && op.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + if("APPLIED".equals(op.get("state")) && op.get("afterSha256") instanceof String sha && sha.matches("[0-9a-f]{64}") + && appliedArchiveId(prepared.id()).equals(op.get("afterArchiveId"))) { + var applied=archive.load(op.getString("afterArchiveId")); + if(!applied.projectId().equals(source.projectId()) || applied.versionIndex()!=0 || !actor.equals(applied.actorId()) + || applied.action()!=ReviewSnapshotArchive.Action.PROJECT_MUTATION_APPLIED || applied.createdAt()!=prepared.createdAt() + || applied.expiresAt()!=prepared.expiresAt() || !sha.equals(digest(applied.versionBytes())))throw invalid(); + permission(permitted);return new Result("APPLIED",sha); + } + return new Result("UNKNOWN",null); + } + private static Document project(ProjectMutationPreparation.Recovered recovered,Document before,Document proposed) { + var prepared=recovered.prepared();Object status=before.get("status"); + if(!(status instanceof String) || !Set.of("DRAFT","PENDING","PUBLISHED","UNLISTED","PRIVATE").contains(status) + )throw invalid(); + boolean classificationChanged=!Objects.equals(before.get("classification"),proposed.get("classification")); + if(classificationChanged && (!(before.get("classification") instanceof String oldType) || !(proposed.get("classification") instanceof String newType) + || !Set.of("DATA","PLUGIN").contains(oldType) || !Set.of("DATA","PLUGIN").contains(newType) + || recovered.transitions().stream().noneMatch(transition->transition.beforeIndex()<0)))throw invalid(); + var old=before.getList("versions",Document.class);var desired=proposed.getList("versions",Document.class);var versions=new ArrayList(Collections.nCopies(desired.size(),null)); + for(var transition:recovered.transitions()) { + if(transition.afterIndex()<0)continue;var next=desired.get(transition.afterIndex());Document original; + if(transition.beforeIndex()<0) { + if(!NEW_FIELDS.containsAll(next.keySet()) || next.get("_class")!=null && !"net.modtale.model.project.ProjectVersion".equals(next.get("_class")))throw invalid(); + for(var field:List.of("retainedRemoteReview","versionMutation","reviewReplacement","reviewIsolation","replacementSecurityHold","findingReviewHead","approvedFindingReviewHead","approvedSecurityEvidence","approvedReviewOrigins","approvedIssueBaselines","securityApprovalProjectId","approvedSecurityContextSha256"))if(next.get(field)!=null)throw invalid(); + if(next.get("reviewStatus")!=null && !"PENDING".equals(next.get("reviewStatus")))throw invalid(); + original=new Document(next);original.put("scanResult",null);original.put("securityApprovedAt",0L); + } else original=old.get(transition.beforeIndex()); + boolean held=transition.beforeIndex()<0 || transition.changes().stream().anyMatch(change->change!=VersionReviewTransition.Change.METADATA) + || prepared.mutation()==ProjectMutationPreparation.Mutation.SUBMISSION && (original.get("scanResult")==null + || original.get("versionMutation")==null && original.get("scanResult") instanceof Document queued + && "SCANNING".equals(queued.get("status")) && queued.get("remoteReview")==null); + if(held && "MODPACK".equals(before.get("classification")) && next.get("fileUrl")==null + && original.get("fileUrl") instanceof String cached && cached.endsWith(".zip") + && transition.changes().contains(VersionReviewTransition.Change.CONTEXT)) { + original=new Document(original);if(next.containsKey("fileUrl"))original.put("fileUrl",null);else original.remove("fileUrl"); + } + if(held)next=VersionMutationExecutor.projectVersion(prepared.id(),transition.beforeSha256()==null?prepared.beforeSha256():transition.beforeSha256(), + prepared.id()+":"+transition.versionId(),original,next,prepared.createdAt()); + else { + // Only the listed owner metadata changes are copied; all review and unknown fields remain byte-preserved. + var retained=new Document(original);for(var field:List.of("gameVersions","dependencies","incompatibleProjectIds","changelog","channel","versionNumber","releaseDate")) { + if(next.containsKey(field))retained.put(field,next.get(field));else retained.remove(field); + }next=retained; + } + versions.set(transition.afterIndex(),next); + } + if(versions.isEmpty() && !Set.of("DRAFT","PRIVATE").contains(status))throw invalid(); + var after=new Document(before);if(classificationChanged)after.put("classification",proposed.get("classification"));after.put("versions",versions);after.put("updatedAt",java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(prepared.createdAt()),java.time.ZoneOffset.UTC).toString()); + if(prepared.mutation()==ProjectMutationPreparation.Mutation.SUBMISSION){after.put("status","PENDING");after.put("expiresAt",null);} + else if(proposed.containsKey("childProjectIds"))after.put("childProjectIds",proposed.get("childProjectIds")); + if(bytes(after).length>ReviewSnapshotArchive.MAX_BYTES)throw invalid();return after; + } + private static String appliedArchiveId(String id){return UUID.nameUUIDFromBytes(("project-mutation-applied-1:"+id).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString();} + private static boolean same(ReviewSnapshotArchive.Snapshot a,ReviewSnapshotArchive.Snapshot b){return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() && a.actorId().equals(b.actorId()) && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes());} + private static Document decode(byte[] data){return new RawBsonDocument(data).decode(new DocumentCodec());} + private static byte[] bytes(Document doc){var buffer=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();var data=new byte[buffer.remaining()];buffer.get(data);return data;} + private static String digest(byte[] data){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(data));}catch(Exception impossible){throw new IllegalStateException(impossible);}} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Grouped mutation is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Grouped mutation cannot be applied to the current state");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationJobAccounting.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationJobAccounting.java new file mode 100644 index 000000000..eab6d566e --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationJobAccounting.java @@ -0,0 +1,85 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.*; +import java.util.function.BooleanSupplier; + +/** Observes archived original jobs only; cannot dispatch, cancel, or clear a held mutation. */ +public final class ProjectMutationJobAccounting implements AutoCloseable { + public static final String COLLECTION="project_mutation_job_observations"; + public record Receipt(String id,String mutationId,String versionId,String state,ReviewOrphanCancellationJournal.Observation observation,Long receivedAt) {} + private final ReviewRepairWorkflow budget;private final ProjectMutationReferenceReader history; + private final ReviewRemoteTargetReader targets;private final ReviewRemoteStatusObserver observer; + public ProjectMutationJobAccounting(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationReferenceReader history, + RemoteReviewClient client,int concurrency) { + this.budget=Objects.requireNonNull(budget);this.history=Objects.requireNonNull(history);targets=new ReviewRemoteTargetReader(mongo); + observer=new ReviewRemoteStatusObserver(mongo,COLLECTION,client,concurrency,Duration.ofSeconds(30)); + } + public Receipt check(String id,Object projectId,String mutationId,String versionId,BooleanSupplier permitted) { + return budget.call(allowed->checkWithinBudget(id,projectId,mutationId,versionId,allowed),permitted); + } + Receipt checkWithinBudget(String id,Object projectId,String mutationId,String versionId,BooleanSupplier permitted) { + return view(mutationId,versionId,observer.check(id,access->target(projectId,mutationId,versionId,access),permitted)); + } + Receipt checkWithinBudget(String id,Object projectId,String mutationId,String versionId,BooleanSupplier permitted,Duration maximum) { + return view(mutationId,versionId,observer.check(id,access->target(projectId,mutationId,versionId,access),permitted,maximum)); + } + public Receipt receipt(String id,Object projectId,String mutationId,String versionId,BooleanSupplier permitted) { + return budget.call(allowed->receiptWithinBudget(id,projectId,mutationId,versionId,allowed),permitted); + } + Receipt receiptWithinBudget(String id,Object projectId,String mutationId,String versionId,BooleanSupplier permitted) { + return view(mutationId,versionId,observer.receipt(id,access->target(projectId,mutationId,versionId,access),permitted)); + } + ReadBatch readBatch(Object projectId,BooleanSupplier allowed){return new ReadBatch(projectId,allowed);} + /** One synchronous read-only pass. No data survives the pass or grants remote execution authority. */ + final class ReadBatch implements AutoCloseable { + private final Object projectId;private final BooleanSupplier allowed;private final Thread owner=Thread.currentThread(); + private final Map groups=new HashMap<>();private long bytes;private boolean closed; + ReadBatch(Object projectId,BooleanSupplier allowed){this.projectId=Objects.requireNonNull(projectId);this.allowed=Objects.requireNonNull(allowed);check();} + Receipt receipt(String id,Object expectedProject,String mutationId,String versionId){ + check();if(!projectId.equals(expectedProject))throw invalid();var group=groups.get(mutationId); + if(group==null){ + if(groups.size()>=8)throw invalid();var verified=history.read(projectId,mutationId,allowed); + bytes+=verified.evidence().before().versionBytes().length+verified.evidence().after().versionBytes().length+verified.applied().versionBytes().length; + if(bytes>64L*1024*1024)throw invalid();group=group(verified);groups.put(mutationId,group); + } + var target=target(projectId,mutationId,versionId,group); + var result=view(mutationId,versionId,observer.receipt(id,permission->{check();return target;},this::permitted));check();return result; + } + private boolean permitted(){check();return true;} + private void check(){if(closed || Thread.currentThread()!=owner)throw invalid();if(!allowed.getAsBoolean())throw new SecurityException("Original job receipt access is not permitted");} + @Override public void close(){if(Thread.currentThread()!=owner)throw invalid();closed=true;groups.clear();} + } + private record Original(Document version,String sha256) {} + private record Group(String actor,String beforeArchiveId,String beforeSha256,long created,long expires,Map originals) {} + private Group group(ProjectMutationReferenceReader.History verified){ + var before=verified.evidence().before();var prepared=verified.evidence().prepared(); + var original=new RawBsonDocument(before.versionBytes()).decode(new DocumentCodec()).getList("versions",Document.class); + var actual=new RawBsonDocument(verified.applied().versionBytes()).decode(new DocumentCodec()).getList("versions",Document.class); + var changed=new HashMap(); + for(var transition:VersionReviewTransition.classify(original,actual)){ + if(transition.beforeIndex()>=0 && transition.changes().stream().anyMatch(change->change!=VersionReviewTransition.Change.METADATA)) + changed.put(transition.versionId(),new Original(original.get(transition.beforeIndex()),transition.beforeSha256())); + } + return new Group(before.actorId(),before.id(),prepared.beforeSha256(),prepared.createdAt(),prepared.expiresAt(),Map.copyOf(changed)); + } + private ReviewRemoteStatusObserver.Target target(Object projectId,String mutationId,String versionId,Group group){ + var original=group.originals().get(versionId);if(original==null)throw invalid();var binding=targets.validate(projectId,original.version()); + var intent=new Document("mutationId",mutationId).append("versionId",versionId).append("actor",group.actor()) + .append("beforeArchiveId",group.beforeArchiveId()).append("beforeSha256",group.beforeSha256()).append("versionSha256",original.sha256()) + .append("createdAt",group.created()).append("expiresAt",group.expires()); + return new ReviewRemoteStatusObserver.Target(intent,binding); + } + private ReviewRemoteStatusObserver.Target target(Object projectId,String mutationId,String versionId,BooleanSupplier permitted) { + return target(projectId,mutationId,versionId,group(history.read(projectId,mutationId,permitted))); + } + private static Receipt view(String mutationId,String versionId,ReviewRemoteStatusObserver.Receipt receipt) { + return new Receipt(receipt.id(),mutationId,versionId,receipt.state(),receipt.observation(),receipt.receivedAt()); + } + private static IllegalStateException invalid(){return new IllegalStateException("Original mutation job evidence is unavailable");} + @Override public void close(){observer.close();} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationObservationProgress.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationObservationProgress.java new file mode 100644 index 000000000..2ac1c8ec4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationObservationProgress.java @@ -0,0 +1,57 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Retained lookup hints only; every reuse authenticates the original observation and job. */ +final class ProjectMutationObservationProgress { + static final String COLLECTION="project_mutation_observation_progress"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection records; + private final ProjectMutationJobAccounting accounting; + ProjectMutationObservationProgress(MongoTemplate mongo,ProjectMutationJobAccounting accounting){ + this.accounting=Objects.requireNonNull(accounting); + records=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS); + } + ProjectMutationJobAccounting.Receipt completed(ProjectMutationAdmissionAttempts.Scope scope,ProjectMutationPriorWorkReader.Work work,BooleanSupplier allowed,ProjectMutationJobAccounting.ReadBatch batch){ + permission(allowed);var identity=identity(scope,work);var stored=read(identity.getString("_id"));if(stored==null)return null; + var id=stored.get("observationId");if(!(id instanceof String value) || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid(); + identity.append("observationId",value);if(!Arrays.equals(bytes(identity),bytes(stored)))throw invalid(); + var receipt=batch.receipt(value,scope.projectId(),work.mutationId(),work.versionId()); + requireCompleted(receipt,work);permission(allowed);return receipt; + } + void retain(ProjectMutationAdmissionAttempts.Scope scope,ProjectMutationPriorWorkReader.Work work,ProjectMutationJobAccounting.Receipt receipt,BooleanSupplier allowed){ + permission(allowed);requireCompleted(receipt,work); + var verified=accounting.receiptWithinBudget(receipt.id(),scope.projectId(),work.mutationId(),work.versionId(),allowed); + if(!receipt.equals(verified))throw invalid(); + var expected=identity(scope,work).append("observationId",receipt.id());permission(allowed); + try{ReviewRepairIo.collection(records).insertOne(expected);}catch(MongoException uncertain){/* Exact read-back below; no observation replay. */} + var stored=read(expected.getString("_id"));if(stored==null || !Arrays.equals(bytes(expected),bytes(stored)))throw invalid();permission(allowed); + } + static boolean isCompleted(ProjectMutationJobAccounting.Receipt receipt){ + if(receipt==null || !"OBSERVED".equals(receipt.state()) || receipt.observation()==null || !"REMOTE_STATUS".equals(receipt.observation().kind()))return false; + var status=receipt.observation().status();return status!=null && "COMPLETED".equals(status.state()) && "COMPLETED".equals(status.workState()); + } + private static void requireCompleted(ProjectMutationJobAccounting.Receipt receipt,ProjectMutationPriorWorkReader.Work work){ + if(!isCompleted(receipt) || !work.mutationId().equals(receipt.mutationId()) || !work.versionId().equals(receipt.versionId()))throw invalid(); + ReviewOrphanCancellationJournal.validateObservation(receipt.observation(),work.binding()); + } + private static Document identity(ProjectMutationAdmissionAttempts.Scope scope,ProjectMutationPriorWorkReader.Work work){ + if(work.kind()!=ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)throw invalid(); + var target=new Document("projectId",scope.projectId()).append("versionId",scope.versionId()).append("mutationId",scope.mutationId()).append("requestId",scope.requestId()).append("scanAttempt",scope.scanAttempt()); + var fields=new Document("scope",target).append("mutationId",work.mutationId()).append("versionId",work.versionId()).append("beforeSha256",work.beforeSha256()); + var result=new Document("_id",UUID.nameUUIDFromBytes(bytes(fields)).toString());result.putAll(fields);return result; + } + private Document read(String id){return ReviewRepairIo.collection(records).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static byte[] bytes(Document doc){var buffer=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Observation progress is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Completed observation progress is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationOwnerAccess.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationOwnerAccess.java new file mode 100644 index 000000000..9a989f3ac --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationOwnerAccess.java @@ -0,0 +1,73 @@ +package net.modtale.service.admin.review; + +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** Internal server-proposal entry point. Upload/context validation belongs to the owning command handler. */ +public final class ProjectMutationOwnerAccess { + private final ReviewRepairWorkflow budget;private final ProjectMutationOwnerAuthority authority; + private final ProjectMutationPreparation preparation;private final ProjectMutationExecutor executor;private final ProjectMutationReferenceReader history; + public ProjectMutationOwnerAccess(ReviewRepairWorkflow budget,ProjectMutationOwnerAuthority authority,ProjectMutationPreparation preparation, + ProjectMutationExecutor executor,ProjectMutationReferenceReader history) { + this.budget=Objects.requireNonNull(budget);this.authority=Objects.requireNonNull(authority);this.preparation=Objects.requireNonNull(preparation); + this.executor=Objects.requireNonNull(executor);this.history=Objects.requireNonNull(history); + } + public ProjectMutationPreparation.Prepared prepare(String id,Object projectId,String expectedSha256,ProjectMutationPreparation.Mutation mutation,byte[] proposed) { + if(proposed==null || proposed.length<5 || proposed.length>ReviewSnapshotArchive.MAX_BYTES)throw invalid(); + byte[] proposal=proposed.clone(); + return budget.call(allowed->{ + String actor=authority.actor();var current=preparation.capture(projectId,allowed); + if(!current.sha256().equals(expectedSha256))throw invalid(); + var required=ProjectMutationPreparation.requiredPermissions(current.bytes(),proposal,projectId,mutation); + var bound=authority.bind(projectId,required);if(!actor.equals(bound.actor()))throw invalid(); + BooleanSupplier permitted=()->allowed.getAsBoolean() && bound.permitted().getAsBoolean(); + history.requireHeldHeads(projectId,current.bytes(),permitted); + return preparation.prepare(new ProjectMutationPreparation.Request(id,projectId,expectedSha256,actor,mutation,proposal),permitted); + },()->true); + } + public ProjectMutationExecutor.Result submitDraft(org.bson.Document original) { + byte[] before=bytes(Objects.requireNonNull(original)); + var proposed=new org.bson.RawBsonDocument(before).decode(new org.bson.codecs.DocumentCodec()); + proposed.put("status","PENDING");proposed.put("expiresAt",null); + for(var version:proposed.getList("versions",org.bson.Document.class)) + if(version.get("reviewStatus")==null || "REJECTED".equals(version.get("reviewStatus")))version.put("reviewStatus","PENDING"); + String sha; + try{sha=java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(before));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + return apply(prepare(java.util.UUID.randomUUID().toString(),original.get("_id"),sha,ProjectMutationPreparation.Mutation.SUBMISSION,bytes(proposed))); + } + public ProjectMutationExecutor.Result editVersion(org.bson.Document original,org.bson.Document proposed) { + byte[] before=bytes(Objects.requireNonNull(original));String sha; + try{sha=java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(before));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + return apply(prepare(java.util.UUID.randomUUID().toString(),original.get("_id"),sha,ProjectMutationPreparation.Mutation.VERSION_LIST,bytes(proposed))); + } + public ProjectMutationExecutor.Result removeVersion(org.bson.Document original,String versionId) { + byte[] before=bytes(Objects.requireNonNull(original)); + var proposed=new org.bson.RawBsonDocument(before).decode(new org.bson.codecs.DocumentCodec()); + var versions=new java.util.ArrayList<>(proposed.getList("versions",org.bson.Document.class)); + if(versionId==null || versions.stream().filter(version->versionId.equals(version.get("_id"))).count()!=1)throw invalid(); + versions.removeIf(version->versionId.equals(version.get("_id")));proposed.put("versions",versions); + String sha; + try{sha=java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(before));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + var prepared=prepare(java.util.UUID.randomUUID().toString(),original.get("_id"),sha,ProjectMutationPreparation.Mutation.VERSION_LIST,bytes(proposed)); + return apply(prepared); + } + private static byte[] bytes(org.bson.Document value) { + var buffer=new org.bson.RawBsonDocument(value,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO(); + if(buffer.remaining()>ReviewSnapshotArchive.MAX_BYTES)throw invalid();var bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes; + } + public ProjectMutationExecutor.Result apply(ProjectMutationPreparation.Prepared prepared) { + Objects.requireNonNull(prepared); + return budget.call(allowed->{ + String actor=authority.actor();var recovered=preparation.recover(prepared.id(),actor,allowed);if(!prepared.equals(recovered.prepared()))throw invalid(); + var before=recovered.before();var required=ProjectMutationPreparation.requiredPermissions(before.versionBytes(),recovered.after().versionBytes(),before.projectId(),prepared.mutation()); + var bound=authority.bind(before.projectId(),required);if(!actor.equals(bound.actor()))throw invalid(); + BooleanSupplier permitted=()->allowed.getAsBoolean() && bound.permitted().getAsBoolean(); + history.requireHeldHeads(before.projectId(),before.versionBytes(),permitted); + return executor.apply(prepared,actor,permitted); + },()->true); + } + private static IllegalStateException invalid(){return new IllegalStateException("Owner mutation state changed");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationOwnerAuthority.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationOwnerAuthority.java new file mode 100644 index 000000000..e1dc3beff --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationOwnerAuthority.java @@ -0,0 +1,79 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.*; +import net.modtale.model.user.*; +import net.modtale.repository.user.ApiKeyRepository; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.context.request.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Internal authority for server-validated owner operations; never grants review approval or cancellation. */ +public final class ProjectMutationOwnerAuthority { + public record Bound(String actor,BooleanSupplier permitted) {} + private static final Set PERMISSIONS=Set.of(ApiKey.ApiPermission.VERSION_CREATE,ApiKey.ApiPermission.VERSION_EDIT, + ApiKey.ApiPermission.VERSION_DELETE,ApiKey.ApiPermission.PROJECT_STATUS_SUBMIT); + private final AccountService accounts;private final AccessControlService access;private final ApiKeyRepository keys;private final MongoTemplate mongo; + public ProjectMutationOwnerAuthority(AccountService accounts,AccessControlService access,ApiKeyRepository keys,MongoTemplate mongo) { + this.accounts=Objects.requireNonNull(accounts);this.access=Objects.requireNonNull(access);this.keys=Objects.requireNonNull(keys);this.mongo=Objects.requireNonNull(mongo); + } + public String actor() { + var user=current(SecurityContextHolder.getContext().getAuthentication());if(user==null)throw denied();return user.getId(); + } + public Bound bind(Object projectId,Set requested) { + if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isBlank() && s.length()<=128) + || requested==null || requested.isEmpty() || !PERMISSIONS.containsAll(requested))throw denied(); + var permissions=Set.copyOf(requested);var authentication=SecurityContextHolder.getContext().getAuthentication(); + var user=current(authentication);if(user==null)throw denied();String actor=user.getId(); + boolean api=access.isApiKey(authentication);ApiKey original=api?requestKey(actor):null; + String keyId=original==null?null:original.getId(),hash=original==null?null:original.getKeyHash(); + BooleanSupplier allowed=()->{ + if(SecurityContextHolder.getContext().getAuthentication()!=authentication)return false; + var refreshed=current(authentication);if(refreshed==null || !actor.equals(refreshed.getId()))return false; + if(permissions.contains(ApiKey.ApiPermission.PROJECT_STATUS_SUBMIT) && !refreshed.isEmailVerified())return false; + var raw=ReviewRepairIo.collection(mongo.getCollection("projects").withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)) + .find(new Document("_id",projectId)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + if(raw==null || !projectId.equals(raw.get("_id")))return false; + var project=mongo.getConverter().read(Project.class,raw); + if(project.getStatus()==null || !(Set.of(ProjectStatus.DRAFT,ProjectStatus.PUBLISHED,ProjectStatus.UNLISTED,ProjectStatus.PRIVATE).contains(project.getStatus()) + || project.getStatus()==ProjectStatus.PENDING && permissions.contains(ApiKey.ApiPermission.PROJECT_STATUS_SUBMIT)))return false; + var key=api?keys.findById(keyId).orElse(null):null; + if(api && (key==null || !actor.equals(key.getUserId()) || !hash.equals(key.getKeyHash())))return false; + for(var permission:permissions) { + if(!access.hasCurrentProjectMembershipPermission(project,refreshed,permission))return false; + if(api && !scoped(key,project,actor,permission))return false; + } + return SecurityContextHolder.getContext().getAuthentication()==authentication && authentication.isAuthenticated(); + }; + if(!allowed.getAsBoolean())throw denied();return new Bound(actor,allowed); + } + private User current(Authentication authentication) { + if(authentication==null || !authentication.isAuthenticated())return null; + var user=accounts.getCurrentUser(authentication);return user==null || user.isDeleted() || user.getId()==null || user.getId().isBlank()?null:user; + } + private ApiKey requestKey(String actor) { + if(!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes))throw denied(); + String supplied=attributes.getRequest().getHeader("X-MODTALE-KEY");if(supplied==null || supplied.length()>256)throw denied(); + supplied=supplied.trim();for(String prefix:List.of("Bearer ","ApiKey "))if(supplied.regionMatches(true,0,prefix,0,prefix.length())){supplied=supplied.substring(prefix.length()).trim();break;} + if(supplied.length()<10)throw denied();var key=keys.findByPrefix(supplied.substring(0,10)).orElse(null); + if(key==null || key.getId()==null || !actor.equals(key.getUserId()) || key.getKeyHash()==null + || !new BCryptPasswordEncoder().matches(supplied,key.getKeyHash()))throw denied();return key; + } + private static boolean scoped(ApiKey key,Project project,String actor,ApiKey.ApiPermission permission) { + var contexts=key.getContextPermissions();if(contexts==null)return false; + if(contexts.getOrDefault(project.getId(),Set.of()).contains(permission))return true; + if(actor.equals(project.getAuthorId()) && contexts.getOrDefault("PERSONAL",Set.of()).contains(permission))return true; + return project.getAuthorId()!=null && contexts.getOrDefault(project.getAuthorId(),Set.of()).contains(permission); + } + private static SecurityException denied(){return new SecurityException("Project mutation is not permitted");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationPreparation.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationPreparation.java new file mode 100644 index 000000000..d6545b9c1 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationPreparation.java @@ -0,0 +1,126 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** One immutable proposal for a grouped project mutation; no child proposal can be executed independently. */ +public final class ProjectMutationPreparation { + public enum Mutation { VERSION_LIST, SUBMISSION } + public record Captured(Object projectId,byte[] bytes,String sha256) { + public Captured{bytes=bytes.clone();} + @Override public byte[] bytes(){return bytes.clone();} + @Override public String toString(){return "CapturedProjectMutation["+sha256+"]";} + } + public record Request(String id,Object projectId,String expectedSha256,String actor,Mutation mutation,byte[] proposedProject) { + public Request{proposedProject=proposedProject==null?null:proposedProject.clone();} + @Override public byte[] proposedProject(){return proposedProject==null?null:proposedProject.clone();} + @Override public String toString(){return "ProjectMutationRequest["+id+"]";} + } + public record Prepared(String id,String beforeArchiveId,String afterArchiveId,String beforeSha256,String afterSha256, + Mutation mutation,long createdAt,long expiresAt) {} + public record Recovered(Prepared prepared,ReviewSnapshotArchive.Snapshot before,ReviewSnapshotArchive.Snapshot after, + List transitions) {public Recovered{transitions=List.copyOf(transitions);}} + private static final Set LIST_FIELDS=Set.of("versions","childProjectIds","classification","updatedAt"); + private static final Set SUBMIT_FIELDS=Set.of("versions","status","expiresAt","updatedAt"); + private final MongoCollection projects;private final ReviewSnapshotArchive archive;private final Clock clock;private final long lifetime; + public ProjectMutationPreparation(MongoTemplate mongo,ReviewSnapshotArchive archive,Clock clock,long lifetime) { + if(lifetime<1000 || lifetime>120000)throw new IllegalArgumentException("Invalid grouped mutation lifetime"); + this.archive=Objects.requireNonNull(archive);this.clock=Objects.requireNonNull(clock);this.lifetime=lifetime; + projects=mongo.getCollection("projects").withDocumentClass(RawBsonDocument.class).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Captured capture(Object projectId,BooleanSupplier permitted) { + permission(permitted);project(projectId); + var raw=ReviewRepairIo.collection(projects).find(new Document("_id",projectId)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first();permission(permitted); + if(raw==null)throw invalid();var buffer=raw.getByteBuffer().asNIO();byte[] data=new byte[buffer.remaining()];buffer.get(data); + var decoded=decode(data);if(!projectId.equals(decoded.get("_id")))throw invalid();versions(decoded); + return new Captured(projectId,data,digest(data)); + } + public Prepared prepare(Request request,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(request); + if(!uuid(request.id()) || request.actor()==null || request.actor().isBlank() || request.actor().length()>256 || request.mutation()==null)throw invalid(); + var captured=capture(request.projectId(),permitted);if(!captured.sha256().equals(request.expectedSha256()))throw invalid(); + validate(captured.bytes(),request.proposedProject(),request.projectId(),request.mutation()); + String beforeId=derived(request.id(),"before"),afterId=derived(request.id(),"after"); + var existing=archive.find(beforeId);permission(permitted); + long created=existing==null?clock.millis():existing.createdAt(),expires=existing==null?Math.addExact(created,lifetime):existing.expiresAt();live(created,expires); + retain(new ReviewSnapshotArchive.Snapshot(beforeId,request.projectId(),0,request.actor(),ReviewSnapshotArchive.Action.PROJECT_MUTATION_BEFORE,created,expires,captured.bytes()),permitted); + retain(new ReviewSnapshotArchive.Snapshot(afterId,request.projectId(),0,request.actor(),ReviewSnapshotArchive.Action.PROJECT_MUTATION_AFTER,created,expires,request.proposedProject()),permitted); + var payload=new Document("schema",1).append("beforeArchiveId",beforeId).append("afterArchiveId",afterId).append("beforeSha256",captured.sha256()) + .append("afterSha256",digest(request.proposedProject())).append("mutation",request.mutation().name()); + retain(new ReviewSnapshotArchive.Snapshot(request.id(),request.projectId(),0,request.actor(),ReviewSnapshotArchive.Action.PROJECT_MUTATION_INTENT,created,expires,bytes(payload)),permitted); + if(!Arrays.equals(captured.bytes(),capture(request.projectId(),permitted).bytes()))throw invalid(); + var result=recover(request.id(),request.actor(),permitted).prepared();live(result.createdAt(),result.expiresAt());return result; + } + public Recovered recover(String id,String actor,BooleanSupplier permitted) { + permission(permitted);var intent=archive.load(id);permission(permitted); + if(intent.action()!=ReviewSnapshotArchive.Action.PROJECT_MUTATION_INTENT || !intent.actorId().equals(actor) || intent.versionIndex()!=0 + || intent.expiresAt()-intent.createdAt()>120000)throw invalid(); + var payload=decode(intent.versionBytes());if(payload.size()!=6 || !Integer.valueOf(1).equals(payload.get("schema")))throw invalid(); + var mutation=Mutation.valueOf(payload.getString("mutation"));String beforeId=derived(id,"before"),afterId=derived(id,"after"); + if(!beforeId.equals(payload.get("beforeArchiveId")) || !afterId.equals(payload.get("afterArchiveId")))throw invalid(); + var before=archive.load(beforeId);permission(permitted);var after=archive.load(afterId);permission(permitted); + header(intent,before,ReviewSnapshotArchive.Action.PROJECT_MUTATION_BEFORE);header(intent,after,ReviewSnapshotArchive.Action.PROJECT_MUTATION_AFTER); + if(!digest(before.versionBytes()).equals(payload.get("beforeSha256")) || !digest(after.versionBytes()).equals(payload.get("afterSha256")))throw invalid(); + var transitions=validate(before.versionBytes(),after.versionBytes(),intent.projectId(),mutation);permission(permitted); + return new Recovered(new Prepared(id,beforeId,afterId,payload.getString("beforeSha256"),payload.getString("afterSha256"),mutation,intent.createdAt(),intent.expiresAt()),before,after,transitions); + } + public static Set requiredPermissions(byte[] beforeBytes,byte[] afterBytes,Object projectId,Mutation mutation) { + Objects.requireNonNull(mutation);var transitions=validate(beforeBytes,afterBytes,projectId,mutation); + var permissions=EnumSet.noneOf(net.modtale.model.user.ApiKey.ApiPermission.class); + if(mutation==Mutation.SUBMISSION)permissions.add(net.modtale.model.user.ApiKey.ApiPermission.PROJECT_STATUS_SUBMIT); + boolean membershipChanged=false,moved=false; + for(var transition:transitions) { + if(transition.beforeIndex()<0){permissions.add(net.modtale.model.user.ApiKey.ApiPermission.VERSION_CREATE);membershipChanged=true;} + else if(transition.afterIndex()<0){permissions.add(net.modtale.model.user.ApiKey.ApiPermission.VERSION_DELETE);membershipChanged=true;} + else { + moved|=transition.beforeIndex()!=transition.afterIndex(); + if(transition.changes().stream().anyMatch(change->mutation!=Mutation.SUBMISSION || change!=VersionReviewTransition.Change.REVIEW)) + permissions.add(net.modtale.model.user.ApiKey.ApiPermission.VERSION_EDIT); + } + } + var before=decode(beforeBytes);var after=decode(afterBytes); + if(moved && !membershipChanged || !Arrays.equals(bytes(new Document("v",before.get("childProjectIds"))),bytes(new Document("v",after.get("childProjectIds"))))) + permissions.add(net.modtale.model.user.ApiKey.ApiPermission.VERSION_EDIT); + if(permissions.isEmpty())throw invalid();return Set.copyOf(permissions); + } + private static List validate(byte[] beforeBytes,byte[] afterBytes,Object projectId,Mutation mutation) { + var before=decode(beforeBytes);var after=decode(afterBytes);if(!projectId.equals(before.get("_id")) || !projectId.equals(after.get("_id")))throw invalid(); + var allowed=mutation==Mutation.VERSION_LIST?LIST_FIELDS:SUBMIT_FIELDS;var fields=new HashSet<>(before.keySet());fields.addAll(after.keySet()); + for(var field:fields)if(!allowed.contains(field) && (before.containsKey(field)!=after.containsKey(field) + || !Arrays.equals(bytes(new Document("value",before.get(field))),bytes(new Document("value",after.get(field))))))throw invalid(); + if(mutation==Mutation.SUBMISSION && (!"DRAFT".equals(before.get("status")) || !"PENDING".equals(after.get("status"))))throw invalid(); + var transitions=VersionReviewTransition.classify(versions(before),versions(after)); + if(mutation==Mutation.SUBMISSION && transitions.stream().anyMatch(t->t.beforeIndex()<0 || t.afterIndex()<0 || t.beforeIndex()!=t.afterIndex()))throw invalid(); + return transitions; + } + private static List versions(Document project) { + if(!(project.get("versions") instanceof List values) || values.stream().anyMatch(value->!(value instanceof Document)))throw invalid(); + var versions=values.stream().map(value->(Document)value).toList();VersionReviewTransition.classify(versions,versions);return versions; + } + private void retain(ReviewSnapshotArchive.Snapshot candidate,BooleanSupplier permitted) { + permission(permitted); + try{archive.retain(candidate);}catch(RuntimeException uncertain){var found=archive.find(candidate.id());if(found==null || !same(candidate,found))throw uncertain;} + permission(permitted); + } + private static boolean same(ReviewSnapshotArchive.Snapshot a,ReviewSnapshotArchive.Snapshot b){return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() && a.actorId().equals(b.actorId()) && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes());} + private static void header(ReviewSnapshotArchive.Snapshot intent,ReviewSnapshotArchive.Snapshot part,ReviewSnapshotArchive.Action action){if(part.action()!=action || part.versionIndex()!=0 || !part.projectId().equals(intent.projectId()) || !part.actorId().equals(intent.actorId()) || part.createdAt()!=intent.createdAt() || part.expiresAt()!=intent.expiresAt())throw invalid();} + private static Document decode(byte[] value){if(value==null || value.length<5 || value.length>ReviewSnapshotArchive.MAX_BYTES)throw invalid();var doc=new RawBsonDocument(value).decode(new DocumentCodec());if(!Arrays.equals(value,bytes(doc)))throw invalid();return doc;} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();byte[] data=new byte[buffer.remaining()];buffer.get(data);return data;} + private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception impossible){throw new IllegalStateException(impossible);}} + private static String derived(String id,String phase){return UUID.nameUUIDFromBytes(("project-mutation-1:"+id+":"+phase).getBytes(StandardCharsets.UTF_8)).toString();} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void project(Object id){if(!(id instanceof ObjectId || id instanceof String s && !s.isEmpty() && s.length()<=128 && StandardCharsets.UTF_8.newEncoder().canEncode(s)))throw invalid();} + private void live(long created,long expires){long now=clock.millis();if(now=expires)throw invalid();} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Grouped mutation access is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Grouped mutation evidence is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationPriorWorkReader.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationPriorWorkReader.java new file mode 100644 index 000000000..31ae4250e --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationPriorWorkReader.java @@ -0,0 +1,87 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.RemoteReviewBinding; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.function.BooleanSupplier; + +/** Authenticated prior-work inventory; absence of a usable job identity is never proof of no work. */ +public final class ProjectMutationPriorWorkReader { + public static final class LimitExceeded extends IllegalStateException { + private LimitExceeded(){super("Prior mutation work exceeds the inspection budget");} + } + public enum Kind { NEW_VERSION, REMOTE_JOB, UNRESOLVED } + public record Work(String mutationId,String versionId,String beforeSha256,Kind kind,RemoteReviewBinding binding,String reason) {} + public record Inventory(String mutationId,List groups,List work) { + public Inventory {groups=List.copyOf(groups);work=List.copyOf(work);} + } + private static final int MAX_GROUPS=8,MAX_WORK=256; + private static final long MAX_BYTES=64L*1024*1024; + private final ProjectMutationAdmissionReader admissions; + private final ReviewRepairWorkflow budget; + private final ProjectMutationReferenceReader history; + private final ReviewRemoteTargetReader targets; + public ProjectMutationPriorWorkReader(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationReferenceReader history) { + admissions=new ProjectMutationAdmissionReader(mongo,history.archive()); + this.budget=Objects.requireNonNull(budget);this.history=Objects.requireNonNull(history);targets=new ReviewRemoteTargetReader(mongo); + } + public Inventory read(Object projectId,String mutationId,BooleanSupplier permitted) { + return budget.call(allowed->readWithinBudget(projectId,mutationId,allowed),permitted); + } + Inventory readWithinBudget(Object projectId,String mutationId,BooleanSupplier permitted) { + var walk=new Walk(projectId,permitted);walk.visit(mutationId);return new Inventory(mutationId,new ArrayList<>(walk.groups.keySet()),walk.work); + } + private final class Walk { + final Object projectId;final BooleanSupplier allowed; + final Map groups=new LinkedHashMap<>(); + final Set active=new HashSet<>(),finished=new HashSet<>();final List work=new ArrayList<>();long retainedBytes; + Walk(Object projectId,BooleanSupplier allowed){this.projectId=projectId;this.allowed=allowed;} + ProjectMutationReferenceReader.History load(String id) { + permission();var found=groups.get(id);if(found!=null)return found; + if(groups.size()>=MAX_GROUPS)throw new LimitExceeded(); + found=history.read(projectId,id,allowed); + retainedBytes+=found.evidence().before().versionBytes().length; + retainedBytes+=found.evidence().after().versionBytes().length; + retainedBytes+=found.applied().versionBytes().length; + if(retainedBytes>MAX_BYTES)throw new LimitExceeded();groups.put(id,found);return found; + } + void visit(String id) { + permission();if(active.contains(id))throw unavailable();if(finished.contains(id))return; + var group=load(id);active.add(id); + var before=versions(group.evidence().before());var after=versions(group.applied()); + for(var change:VersionReviewTransition.classify(before,after)) { + permission(); + if(change.changes().stream().noneMatch(c->c!=VersionReviewTransition.Change.METADATA))continue; + if(change.beforeIndex()<0) {add(new Work(id,change.versionId(),null,Kind.NEW_VERSION,null,"NO_PREVIOUS_VERSION"));continue;} + var original=before.get(change.beforeIndex());Object pointer=original.get("versionMutation"); + var scan=original.get("scanResult") instanceof Document doc?doc:null; + if(pointer!=null && scan!=null && "MUTATION_HELD".equals(scan.get("scanState"))) { + if(!(pointer instanceof Document ref) || ref.size()!=3 || !(ref.get("operationId") instanceof String previous))throw unavailable(); + admissions.requireUnadmitted(ref.getString("requestId"),allowed); + var prior=load(previous);var matching=versions(prior.applied()).stream().filter(v->change.versionId().equals(v.get("_id"))).toList(); + if(matching.size()!=1 || VersionReviewTransition.classify(matching,List.of(original)).getFirst().changes().stream() + .anyMatch(c->c!=VersionReviewTransition.Change.METADATA))throw unavailable(); + visit(previous);continue; + } + if(scan!=null && "MUTATION_HELD".equals(scan.get("scanState")))throw unavailable(); + if(pointer!=null)admissions.requireHead(projectId,original,allowed); + else admissions.requireNoAdmission(original,allowed); + RemoteReviewBinding binding=null; + try{binding=targets.validate(projectId,original);}catch(IllegalStateException missing){ /* Explicit uncertainty below. */ } + add(new Work(id,change.versionId(),change.beforeSha256(),binding==null?Kind.UNRESOLVED:Kind.REMOTE_JOB,binding, + binding==null?"ORIGINAL_JOB_IDENTITY_UNAVAILABLE":null)); + if(original.get("reviewReplacement")!=null || original.get("reviewIsolation")!=null) + add(new Work(id,change.versionId(),change.beforeSha256(),Kind.UNRESOLVED,null,"PRIOR_TRANSITION_REQUIRES_ACCOUNTING")); + } + active.remove(id);finished.add(id);permission(); + } + void add(Work item){if(work.size()>=MAX_WORK)throw new LimitExceeded();work.add(item);} + void permission(){if(!allowed.getAsBoolean())throw new SecurityException("Prior work access is not permitted");} + } + private static List versions(ReviewSnapshotArchive.Snapshot snapshot) { + return new RawBsonDocument(snapshot.versionBytes()).decode(new DocumentCodec()).getList("versions",Document.class); + } + private static IllegalStateException unavailable(){return new IllegalStateException("Prior mutation work is unavailable or exceeds the inspection budget");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationRecoveryScheduler.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationRecoveryScheduler.java new file mode 100644 index 000000000..8f471168d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationRecoveryScheduler.java @@ -0,0 +1,15 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.BoundedReviewScheduler; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; + +public final class ProjectMutationRecoveryScheduler extends BoundedReviewScheduler { + private final AtomicLong unavailable; + public ProjectMutationRecoveryScheduler(ProjectMutationAttemptRecovery recovery,Settings settings){this(recovery,settings,new AtomicLong());} + private ProjectMutationRecoveryScheduler(ProjectMutationAttemptRecovery recovery,Settings settings,AtomicLong unavailable) { + super("mutation-admission-recovery",(cursor,limit)->{var page=recovery.page(cursor,limit);unavailable.addAndGet(page.unavailable());return new Page<>(page.candidates(),page.next());},recovery::recover,settings); + Objects.requireNonNull(recovery);this.unavailable=unavailable; + } + public long unavailableCandidates(){return unavailable.get();} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationReferenceReader.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationReferenceReader.java new file mode 100644 index 000000000..d422ad7b7 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationReferenceReader.java @@ -0,0 +1,124 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Project-scoped discovery and authenticated historical reads; neither activates a held request. */ +public final class ProjectMutationReferenceReader { + public static final String INDEX="project_mutation_project_id"; + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + public record Page(List operationIds,String nextCursor) {public Page{operationIds=List.copyOf(operationIds);}} + public record History(ProjectMutationPreparation.Recovered evidence,ProjectMutationExecutor.Result receipt,ReviewSnapshotArchive.Snapshot applied) {} + private final ProjectMutationAdmissionReader admissions; + private final MongoCollection references; + private final ReviewSnapshotArchive archive; + private final ProjectMutationPreparation preparation; + private final ProjectMutationExecutor executor; + public ProjectMutationReferenceReader(MongoTemplate mongo,ReviewSnapshotArchive archive,ProjectMutationPreparation preparation,ProjectMutationExecutor executor) { + admissions=new ProjectMutationAdmissionReader(mongo,archive); + this.archive=Objects.requireNonNull(archive);this.preparation=Objects.requireNonNull(preparation);this.executor=Objects.requireNonNull(executor); + references=mongo.getCollection(ProjectMutationExecutor.REFERENCES).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withTimeout(5000,TimeUnit.MILLISECONDS); + } + ReviewSnapshotArchive archive(){return archive;} + public void initialize() { + references.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).createIndex(new Document("projectId",1).append("_id",1), + new IndexOptions().name(INDEX).collation(BINARY)); + } + public Page page(Object projectId,String cursor,int limit,BooleanSupplier permitted) { + permission(permitted);project(projectId);String scope=scope(projectId); + if(limit<1 || limit>64 || cursor!=null && !cursor.matches("g1\\."+scope+"\\."+UUID))throw invalid(); + var id=new Document("$type","string").append("$regex","^"+UUID+"$");if(cursor!=null)id.append("$gt",cursor.substring(68)); + var query=new Document("projectId",projectId).append("_id",id) + .append("$expr",new Document("$and",List.of(new Document("$eq",List.of("$projectId",new Document("$literal",projectId)))))); + var rows=ReviewRepairIo.collection(references).find(query).hintString(INDEX).collation(BINARY).sort(new Document("_id",1)) + .projection(new Document("_id",1)).limit(limit+1).batchSize(limit+1).maxTime(5,TimeUnit.SECONDS).into(new ArrayList<>()); + permission(permitted);var ids=rows.stream().limit(limit).map(row->row.getString("_id")).toList(); + return new Page(ids,rows.size()>limit?"g1."+scope+"."+ids.getLast():null); + } + public History read(Object projectId,String id,BooleanSupplier permitted) { + permission(permitted);project(projectId);if(id==null || !id.matches(UUID))throw invalid(); + var reference=ReviewRepairIo.collection(references).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(permitted); + if(reference==null || !projectId.equals(reference.get("projectId")))throw unavailable(); + var intent=archive.load(id);permission(permitted); + // Historical attribution selects the record; the current caller's permission remains independently required. + var recovered=preparation.recover(id,intent.actorId(),permitted);var prepared=recovered.prepared();var before=recovered.before(); + if(!projectId.equals(before.projectId()))throw unavailable(); + var receipt=executor.receipt(prepared,intent.actorId(),permitted);if(!"APPLIED".equals(receipt.state()))throw unavailable(); + if(!(reference.get("afterArchiveId") instanceof String appliedId))throw unavailable(); + var applied=archive.load(appliedId); + if(!appliedId.equals(java.util.UUID.nameUUIDFromBytes(("project-mutation-applied-1:"+id).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString()) + || !projectId.equals(applied.projectId()) || applied.versionIndex()!=0 || !intent.actorId().equals(applied.actorId()) + || applied.action()!=ReviewSnapshotArchive.Action.PROJECT_MUTATION_APPLIED || applied.createdAt()!=prepared.createdAt() + || applied.expiresAt()!=prepared.expiresAt() || !digest(applied.versionBytes()).equals(receipt.afterSha256()))throw unavailable(); + var original=new RawBsonDocument(before.versionBytes()).decode(new DocumentCodec()); + var committed=new RawBsonDocument(applied.versionBytes()).decode(new DocumentCodec()); + var versions=committed.getList("versions",Document.class);var changes=new ArrayList(); + for(var transition:VersionReviewTransition.classify(original.getList("versions",Document.class),versions)) { + if(Objects.equals(transition.beforeSha256(),transition.afterSha256()) && transition.beforeIndex()==transition.afterIndex())continue; + var version=transition.afterIndex()<0?null:versions.get(transition.afterIndex());var scan=version==null?null:version.get("scanResult",Document.class); + String state=version==null?"REMOVED":scan!=null && "MUTATION_HELD".equals(scan.get("scanState"))?"HELD":"RETAINED"; + changes.add(new Document("versionId",transition.versionId()).append("beforeIndex",transition.beforeIndex()).append("afterIndex",transition.afterIndex()) + .append("beforeSha256",transition.beforeSha256()).append("afterSha256",transition.afterSha256()).append("state",state)); + } + var expected=new Document("_id",id).append("projectId",projectId).append("actor",intent.actorId()) + .append("beforeArchiveId",prepared.beforeArchiveId()).append("beforeSha256",prepared.beforeSha256()) + .append("afterArchiveId",appliedId).append("afterSha256",receipt.afterSha256()).append("versions",changes); + if(!Arrays.equals(bytes(expected),bytes(reference)))throw unavailable(); + permission(permitted);return new History(recovered,receipt,applied); + } + /** Verifies held projections or authenticated admission provenance; never grants clearance. */ + public void requireHeldHeads(Object projectId,byte[] projectBytes,BooleanSupplier permitted) { + permission(permitted);project(projectId); + if(projectBytes==null || projectBytes.length>ReviewSnapshotArchive.MAX_BYTES)throw unavailable(); + var current=new RawBsonDocument(projectBytes).decode(new DocumentCodec()); + if(!projectId.equals(current.get("_id")) || !Arrays.equals(projectBytes,bytes(current)))throw unavailable(); + var versions=current.getList("versions",Document.class);VersionReviewTransition.classify(versions,versions); + var groups=new HashMap>(); + for(var version:versions) { + permission(permitted);Object pointer=version.get("versionMutation"); + var scan=version.get("scanResult") instanceof Document doc?doc:null; + if(pointer==null) { + admissions.requireNoAdmission(version,permitted); + if(scan!=null && "MUTATION_HELD".equals(scan.get("scanState")))throw unavailable(); + continue; + } + if(!(pointer instanceof Document ref) || ref.size()!=3 || !(ref.get("operationId") instanceof String id) + || !id.matches(UUID) || !(ref.get("beforeSha256") instanceof String sha) || !sha.matches("[0-9a-f]{64}") + || !(ref.get("requestId") instanceof String request) || !request.matches(UUID))throw unavailable(); + var archived=groups.get(id); + if(archived==null) { + var verified=read(projectId,id,permitted);var root=new RawBsonDocument(verified.applied().versionBytes()).decode(new DocumentCodec()); + archived=new HashMap<>();for(var item:root.getList("versions",Document.class))archived.put(item.getString("_id"),item); + groups.put(id,archived); + } + var original=archived.get(version.getString("_id"));if(original==null)throw unavailable(); + if(scan==null || !"MUTATION_HELD".equals(scan.get("scanState"))) { + admissions.requireHead(projectId,version,permitted);continue; + } + admissions.requireUnadmitted(request,permitted); + var difference=VersionReviewTransition.classify(List.of(original),List.of(version)).getFirst(); + if(difference.changes().stream().anyMatch(change->change!=VersionReviewTransition.Change.METADATA))throw unavailable(); + } + permission(permitted); + } + private static void project(Object id) { + if(!(id instanceof ObjectId || id instanceof String s && !s.isEmpty() && s.length()<=128 + && java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(s)))throw invalid(); + } + private static String scope(Object id){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes(new Document("projectId",id))));}catch(Exception invalid){throw new IllegalArgumentException("Invalid project scope",invalid);}} + private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Project mutation history access is not permitted");} + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid mutation history request");} + private static IllegalStateException unavailable(){return new IllegalStateException("Retained mutation history is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationWorkflow.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationWorkflow.java new file mode 100644 index 000000000..fa9d79a00 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationWorkflow.java @@ -0,0 +1,48 @@ +package net.modtale.service.admin.review; + +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** Shares repair admission and deadlines; callers must supply fresh project/account authority. */ +public final class ProjectMutationWorkflow { + private final ReviewRepairWorkflow budget; + private final ProjectMutationPreparation preparation; + private final ProjectMutationExecutor executor; + private final ProjectMutationReferenceReader history; + public ProjectMutationWorkflow(ReviewRepairWorkflow budget,ProjectMutationPreparation preparation, + ProjectMutationExecutor executor,ProjectMutationReferenceReader history) { + this.budget=Objects.requireNonNull(budget);this.preparation=Objects.requireNonNull(preparation); + this.executor=Objects.requireNonNull(executor);this.history=Objects.requireNonNull(history); + } + public ProjectMutationPreparation.Captured capture(Object projectId,BooleanSupplier permitted) { + return budget.call(allowed->preparation.capture(projectId,allowed),permitted); + } + public ProjectMutationPreparation.Prepared prepare(ProjectMutationPreparation.Request request,BooleanSupplier permitted) { + return budget.call(allowed->{ + var current=preparation.capture(request.projectId(),allowed); + if(!current.sha256().equals(request.expectedSha256()))throw new IllegalStateException("Project mutation state changed"); + history.requireHeldHeads(current.projectId(),current.bytes(),allowed); + return preparation.prepare(request,allowed); + },permitted); + } + public ProjectMutationExecutor.Result apply(ProjectMutationPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + return budget.call(allowed->{ + var recovered=preparation.recover(prepared.id(),actor,allowed); + if(!prepared.equals(recovered.prepared()))throw new IllegalStateException("Project mutation evidence changed"); + history.requireHeldHeads(recovered.before().projectId(),recovered.before().versionBytes(),allowed); + return executor.apply(prepared,actor,allowed); + },permitted); + } + public ProjectMutationPreparation.Recovered recover(String id,String actor,BooleanSupplier permitted) { + return budget.call(allowed->preparation.recover(id,actor,allowed),permitted); + } + public ProjectMutationExecutor.Result receipt(ProjectMutationPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + return budget.call(allowed->executor.receipt(prepared,actor,allowed),permitted); + } + public ProjectMutationReferenceReader.Page page(Object projectId,String cursor,int limit,BooleanSupplier permitted) { + return budget.call(allowed->history.page(projectId,cursor,limit,allowed),permitted); + } + public ProjectMutationReferenceReader.History history(Object projectId,String id,BooleanSupplier permitted) { + return budget.call(allowed->history.read(projectId,id,allowed),permitted); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java index b30471024..d1ad2fd51 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java @@ -1,8 +1,6 @@ package net.modtale.service.admin.review; -import java.util.List; import net.modtale.model.dto.admin.AdminProjectReviewDTO; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; import net.modtale.model.user.User; import org.springframework.stereotype.Service; @@ -20,27 +18,23 @@ public ProjectReviewAdminService( this.projectReviewDecisionService = projectReviewDecisionService; } - public List getVerificationQueue() { - return projectReviewQueryService.getVerificationQueue(); - } - public AdminProjectReviewDTO getProjectReviewDetails(String id) { return projectReviewQueryService.getProjectReviewDetails(id); } - public void publishProject(User adminUser, String id) { - projectReviewDecisionService.publishProject(adminUser, id); + public void publishProject(User adminUser, String id, String reviewToken, String versionId) { + projectReviewDecisionService.publishProject(adminUser, id, reviewToken, versionId); } - public void approveVersion(User adminUser, String id, String versionId) { - projectReviewDecisionService.approveVersion(adminUser, id, versionId); + public void approveVersion(User adminUser, String id, String versionId, String reviewToken) { + projectReviewDecisionService.approveVersion(adminUser, id, versionId, reviewToken); } - public void rejectVersion(User adminUser, String id, String versionId, String reason) { - projectReviewDecisionService.rejectVersion(adminUser, id, versionId, reason); + public void rejectVersion(User adminUser, String id, String versionId, String reason, String reviewToken) { + projectReviewDecisionService.rejectVersion(adminUser, id, versionId, reason, reviewToken); } - public void rejectProject(User adminUser, String id, String reason) { - projectReviewDecisionService.rejectProject(adminUser, id, reason); + public void rejectProject(User adminUser, String id, String reason, String reviewToken) { + projectReviewDecisionService.rejectProject(adminUser, id, reason, reviewToken); } } diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java index 79b08044b..f72f51160 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java @@ -17,26 +17,26 @@ public ProjectReviewDecisionService( this.projectReviewEffectService = projectReviewEffectService; } - public void publishProject(User adminUser, String id) { - projectReviewTransitionService.publishProject(adminUser, id); + public void publishProject(User adminUser, String id, String reviewToken, String versionId) { + projectReviewTransitionService.publishProject(adminUser, id, reviewToken, versionId); projectReviewEffectService.onProjectPublished(adminUser, id); } - public void approveVersion(User adminUser, String id, String versionId) { + public void approveVersion(User adminUser, String id, String versionId, String reviewToken) { ProjectReviewTransitionService.VersionReviewDecision decision = - projectReviewTransitionService.approveVersion(id, versionId); + projectReviewTransitionService.approveVersion(id, versionId, reviewToken); projectReviewEffectService.onVersionApproved(adminUser, id, versionId, decision); } - public void rejectVersion(User adminUser, String id, String versionId, String reason) { + public void rejectVersion(User adminUser, String id, String versionId, String reason, String reviewToken) { ProjectReviewTransitionService.VersionReviewDecision decision = - projectReviewTransitionService.rejectVersion(id, versionId, reason); + projectReviewTransitionService.rejectVersion(id, versionId, reason, reviewToken); projectReviewEffectService.onVersionRejected(adminUser, id, versionId, decision); } - public void rejectProject(User adminUser, String id, String reason) { + public void rejectProject(User adminUser, String id, String reason, String reviewToken) { ProjectReviewTransitionService.ProjectRejectionDecision decision = - projectReviewTransitionService.rejectProject(id, reason); + projectReviewTransitionService.rejectProject(id, reason, reviewToken); projectReviewEffectService.onProjectRejected(adminUser, id, decision); } } diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java new file mode 100644 index 000000000..367f30a26 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java @@ -0,0 +1,265 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectVersion; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; +import java.util.*; + +@Service +public class ProjectReviewPersistence { + private final MongoTemplate mongo; + public ProjectReviewPersistence(MongoTemplate mongo) { this.mongo = mongo; } + public record Snapshot(Document raw, Project project) {} + public Snapshot capture(String id, String token) { + var entity = mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var query = new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id", id), entity); + var raw = mongo.getCollection(mongo.getCollectionName(Project.class)).find(query).first(); + if (raw == null) throw ProjectReviewSnapshot.conflict(); + var project = mongo.getConverter().read(Project.class, raw); + ProjectReviewSnapshot.requireCurrent(project, token); + return new Snapshot(raw, project); + } + public Document versionUploadProposal(Snapshot snapshot) { + var codec=new org.bson.codecs.DocumentCodec();var proposed=new org.bson.RawBsonDocument(snapshot.raw(),codec).decode(codec); + var originals=new HashMap();for(var original:proposed.getList("versions",Document.class)) + if(originals.put(original.getString("_id"),original)!=null)throw ProjectReviewSnapshot.conflict(); + var versions=new ArrayList(); + for(var version:snapshot.project().getVersions()) { + var mapped=new Document();mongo.getConverter().write(version,mapped);var original=originals.get(version.getId()); + if(original==null)versions.add(mapped); + else {if(mapped.containsKey("gameVersions"))original.put("gameVersions",mapped.get("gameVersions"));else original.remove("gameVersions");versions.add(original);} + } + proposed.put("versions",versions);proposed.put("classification",snapshot.project().getClassification()==null?null:snapshot.project().getClassification().name()); + proposed.put("childProjectIds",snapshot.project().getChildProjectIds());return proposed; + } + public Document versionEditProposal(Snapshot snapshot,String versionId,boolean childIdsChanged) { + var codec=new org.bson.codecs.DocumentCodec(); + var proposed=new org.bson.RawBsonDocument(snapshot.raw(),codec).decode(codec); + var targets=snapshot.project().getVersions().stream().filter(version->versionId.equals(version.getId())).toList(); + var originals=proposed.getList("versions",Document.class).stream().filter(version->versionId.equals(version.get("_id"))).toList(); + if(targets.size()!=1 || originals.size()!=1)throw ProjectReviewSnapshot.conflict(); + var mapped=new Document();mongo.getConverter().write(targets.getFirst(),mapped);var target=originals.getFirst(); + for(var field:List.of("gameVersions","dependencies","incompatibleProjectIds","changelog","channel","fileUrl")) { + if(mapped.containsKey(field))target.put(field,mapped.get(field));else target.remove(field); + } + if(childIdsChanged)proposed.put("childProjectIds",snapshot.project().getChildProjectIds());return proposed; + } + public boolean apply(Snapshot snapshot, String approvedVersionId) { + Project project = snapshot.project(); + var originGuard = new org.springframework.data.mongodb.core.query.Query(); + var update = new Update().set("status", project.getStatus()).set("expiresAt", project.getExpiresAt()) + .set("updatedAt", project.getUpdatedAt()).set("createdAt", project.getCreatedAt()) + .set("approvedBy", project.getApprovedBy()).set("imageUrl", project.getImageUrl()) + .set("rankingDirty", true); + if (approvedVersionId != null) { + var versions = project.getVersions(); + int index = -1; + for (int i = 0; i < versions.size(); i++) if (approvedVersionId.equals(versions.get(i).getId())) { + if (index >= 0) throw ProjectReviewSnapshot.conflict(); + index = i; + } + if (index < 0) throw ProjectReviewSnapshot.conflict(); + ProjectVersion version = versions.get(index); + var original = mongo.getConverter().read(ProjectVersion.class, snapshot.raw().getList("versions", Document.class).get(index)); + if (!approvedVersionId.equals(original.getId())) throw ProjectReviewSnapshot.conflict(); + if (version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED) + net.modtale.service.security.issue.FindingReviewHistory.requireManualApproval(mongo, project.getId(), original); + version.setApprovedFindingReviewHead(version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED + ? original.getFindingReviewHead() : null); + var scan = original.getScanResult(); + if (version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED && scan != null && scan.getReusedReviewVersion() != null + && (!net.modtale.service.security.scan.ArtifactClearancePolicy.complete(scan) + || !net.modtale.service.security.scan.ArtifactReviewLineage.bind(mongo, project.getId(), scan, originGuard))) return false; + String path = "versions." + index + "."; + update.set(path + "reviewStatus", version.getReviewStatus()).set(path + "rejectionReason", version.getRejectionReason()) + .set(path + "scheduledPublishDate", version.getScheduledPublishDate()).set(path + "scanResult", version.getScanResult()) + .set(path + "retainedRemoteReview", scan!=null && scan.getRemoteReview()!=null && version.getScanResult()==null + ? scan.getRemoteReview() : original.getRetainedRemoteReview()) + .set(path + "securityApprovalProjectId", version.getSecurityApprovalProjectId()) + .set(path + "approvedReviewOrigins", version.getApprovedReviewOrigins()) + .set(path + "approvedFindingReviewHead", version.getApprovedFindingReviewHead()) + .set(path + "approvedSecurityEvidence", version.getApprovedSecurityEvidence()) + .set(path + "approvedSecurityContextSha256", version.getApprovedSecurityContextSha256()) + .set(path + "securityApprovedAt", version.getSecurityApprovedAt()) + .set(path + "approvedIssueBaselines", version.getApprovedIssueBaselines()); + } + return applyUpdate(snapshot, update, originGuard.getQueryObject().get("$expr")); + } + public boolean submitDraft(Snapshot snapshot) { + if (snapshot.project().getStatus() != net.modtale.model.project.ProjectStatus.PENDING + || !"DRAFT".equals(snapshot.raw().getString("status"))) throw ProjectReviewSnapshot.conflict(); + var update = new Update().set("status", net.modtale.model.project.ProjectStatus.PENDING) + .set("expiresAt", null).set("updatedAt", java.time.LocalDateTime.now().toString()); + var originals = snapshot.raw().getList("versions", Document.class, List.of()); + var versions = snapshot.project().getVersions(); + if (originals.size() != versions.size()) throw ProjectReviewSnapshot.conflict(); + for (int i = 0; i < versions.size(); i++) { + var original = mongo.getConverter().read(ProjectVersion.class, originals.get(i)); + var version = versions.get(i); + if (!Objects.equals(original.getId(), version.getId())) throw ProjectReviewSnapshot.conflict(); + String path = "versions." + i + "."; + if (original.getScanResult() == null && version.getScanResult() != null) { + update.set(path + "scanResult", version.getScanResult()).set(path + "reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set(path + "scheduledPublishDate", null); + } else if (original.getReviewStatus() == null || original.getReviewStatus() == ProjectVersion.ReviewStatus.REJECTED) { + update.set(path + "reviewStatus", ProjectVersion.ReviewStatus.PENDING); + } + } + return applyUpdate(snapshot, update); + } + public boolean applyTeam(Snapshot snapshot) { return applyTeam(snapshot, null); } + public boolean resolveContributorInvite(Snapshot snapshot, String userId, String requestId, boolean accepting) { + var original = mongo.getConverter().read(Project.class, snapshot.raw()); + var invite = net.modtale.service.project.team.ProjectInvitationPolicy.require(original, userId, requestId, accepting); + return applyTeam(snapshot, accepting ? new Document("$gt", List.of(invite.getRequestExpiresAt(), new Document("$toLong", "$$NOW"))) : null); + } + public boolean resolveTransfer(Snapshot snapshot, String requestId) { + if (requestId == null || !requestId.equals(snapshot.raw().getString("pendingTransferRequestId")) + || snapshot.raw().get("pendingTransferOwnerId") == null + || !Objects.equals(snapshot.raw().get("pendingTransferOwnerId"), snapshot.raw().get("authorId"))) + throw ProjectReviewSnapshot.conflict(); + return applyTeam(snapshot, new Document("$gt", List.of("$pendingTransferExpiresAt", new Document("$toLong", "$$NOW")))); + } + private boolean applyTeam(Snapshot snapshot, Object guard) { + var encoded = new Document(); mongo.getConverter().write(snapshot.project(), encoded); + var update = new Update(); + for (String field : List.of("authorId", "author", "pendingTransferTo", "pendingTransferRequestId", "pendingTransferOwnerId", "pendingTransferExpiresAt", "teamMembers", "teamInvites", "projectRoles")) + update.set(field, encoded.get(field)); + update.set("updatedAt", java.time.LocalDateTime.now().toString()); + return applyUpdate(snapshot, update, guard); + } + public boolean cacheModpackArchive(String projectId, String projectToken, String versionId, String versionToken, String fileUrl) { + if (fileUrl == null || fileUrl.isBlank() || versionId == null || versionId.isBlank()) return false; + var snapshot = capture(projectId, projectToken); + if (snapshot.project().getClassification() != net.modtale.model.project.ProjectClassification.MODPACK) + throw ProjectReviewSnapshot.conflict(); + var versions = snapshot.project().getVersions(); + int index = -1; + for (int i = 0; i < versions.size(); i++) { + if (!Objects.equals(versionId, versions.get(i).getId())) continue; + if (index != -1 || !Objects.equals(versionToken, VersionReviewSnapshot.token(versions.get(i)))) + throw ProjectReviewSnapshot.conflict(); + index = i; + } + if (index == -1) throw ProjectReviewSnapshot.conflict(); + return applyUpdate(snapshot, new Update().set("versions." + index + ".fileUrl", fileUrl)); + } + public boolean applyComments(Snapshot snapshot) { + return applyUpdate(snapshot, new Update().set("comments", snapshot.project().getComments())); + } + public boolean unlist(Snapshot snapshot) { + if (!"PUBLISHED".equals(snapshot.raw().getString("status")) + && !"UNLISTED".equals(snapshot.raw().getString("status"))) + throw ProjectReviewSnapshot.conflict(); + return applyUpdate(snapshot, new Update().set("status", net.modtale.model.project.ProjectStatus.UNLISTED) + .set("expiresAt", null).set("rankingDirty", true) + .set("updatedAt", java.time.LocalDateTime.now().toString())); + } + public boolean applyDeletionState(Snapshot snapshot, boolean scrub) { + var project = snapshot.project(); + var update = new Update().set("status", project.getStatus()).set("deletedAt", project.getDeletedAt()) + .set("updatedAt", java.time.LocalDateTime.now().toString()).set("rankingDirty", true); + if (scrub) { + var encoded = new Document(); mongo.getConverter().write(project, encoded); + for (String field : List.of("title", "description", "about", "slug", "imageUrl", "bannerUrl", "galleryImages", + "galleryImageCaptions", "teamMembers", "teamInvites", "projectRoles", "comments", "tags")) + update.set(field, encoded.get(field)); + } + return applyUpdate(snapshot, update); + } + public boolean deleteProject(Snapshot snapshot) { + var query = new Document("_id", snapshot.raw().get("_id")).append("$expr", + new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw())))); + return mongo.getCollection(mongo.getCollectionName(Project.class)).deleteOne(query).getDeletedCount() == 1; + } + public boolean applyPresentation(Snapshot snapshot, boolean media) { + var fields = media ? List.of("imageUrl", "bannerUrl", "galleryImages", "galleryImageCaptions") + : List.of("classification", "tags", "title", "description", "about", "categories", "slug", "license", + "customLicenseOpenSource", "repositoryUrl", "types", "allowModpacks", "allowComments", + "hmWikiEnabled", "hmWikiSlug", "galleryCarouselEnabled", "links", "imageUrl"); + var encoded = new Document(); mongo.getConverter().write(snapshot.project(), encoded); + var update = new Update(); + for (var field : fields) update.set(field, encoded.get(field)); + update.set("updatedAt", java.time.LocalDateTime.now().toString()).set("rankingDirty", true); + return applyUpdate(snapshot, update); + } + public boolean applyVersionList(Snapshot snapshot) { + var original = new HashMap(); + for (var raw : snapshot.raw().getList("versions", Document.class, List.of())) { + var version = mongo.getConverter().read(ProjectVersion.class, raw); + if (version.getId() == null || original.put(version.getId(), raw) != null) throw ProjectReviewSnapshot.conflict(); + } + var seen = new HashSet(); + var updated = new ArrayList(); + for (var version : snapshot.project().getVersions()) { + if (version.getId() == null || !seen.add(version.getId())) throw ProjectReviewSnapshot.conflict(); + var prior = original.get(version.getId()); + if (prior == null) { + if (version.getReviewStatus() != ProjectVersion.ReviewStatus.PENDING) throw ProjectReviewSnapshot.conflict(); + var added = new Document(); mongo.getConverter().write(version, added); updated.add(added); + } else { + // Retained versions keep every stored field, including evidence unknown to this binary. + var retained = new Document(prior); + var before = mongo.getConverter().read(ProjectVersion.class, prior); + if (!Objects.equals(before.getGameVersions(), version.getGameVersions())) { + retained.put("gameVersions", version.getGameVersions()); + retained.put("reviewStatus", "PENDING"); retained.put("scheduledPublishDate", null); + retained.put("scanResult", mongo.getConverter().convertToMongoType(version.getScanResult())); + for (String field : List.of("approvedSecurityEvidence", "approvedSecurityContextSha256", "approvedReviewOrigins", + "securityApprovalProjectId", "approvedFindingReviewHead", "approvedIssueBaselines")) retained.put(field, null); + retained.put("securityApprovedAt", 0L); + } + updated.add(retained); + } + } + return applyUpdate(snapshot, new Update().set("versions", updated) + .set("classification", snapshot.project().getClassification()) + .set("childProjectIds", snapshot.project().getChildProjectIds()) + .set("updatedAt", java.time.LocalDateTime.now().toString())); + } + public boolean applyVersionEdit(Snapshot snapshot, String versionId, boolean contextChanged, boolean childIdsChanged) { + var versions = snapshot.project().getVersions(); + int index = -1; + for (int i = 0; i < versions.size(); i++) if (versionId.equals(versions.get(i).getId())) { + if (index >= 0) throw ProjectReviewSnapshot.conflict(); + index = i; + } + if (index < 0) throw ProjectReviewSnapshot.conflict(); + var version = versions.get(index); + String path = "versions." + index + "."; + var update = new Update().set(path + "gameVersions", version.getGameVersions()) + .set(path + "dependencies", version.getDependencies()).set(path + "incompatibleProjectIds", version.getIncompatibleProjectIds()) + .set(path + "changelog", version.getChangelog()).set(path + "channel", version.getChannel()) + .set(path + "fileUrl", version.getFileUrl()); + if (childIdsChanged) update.set("childProjectIds", snapshot.project().getChildProjectIds()); + if (contextChanged) update.set(path + "reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set(path + "scheduledPublishDate", null).set(path + "scanResult", version.getScanResult()) + .set(path + "approvedSecurityEvidence", null).set(path + "approvedSecurityContextSha256", null) + .set(path + "securityApprovedAt", 0).set(path + "approvedReviewOrigins", null) + .set(path + "securityApprovalProjectId", null).set(path + "approvedFindingReviewHead", null) + .set(path + "approvedIssueBaselines", null); + update.set("updatedAt", java.time.LocalDateTime.now().toString()); + return applyUpdate(snapshot, update); + } + public boolean applyMetadataRepair(Snapshot snapshot, Map metadata) { + net.modtale.service.admin.project.ProjectMetadataRepair.validate(metadata); + var update = new Update(); + metadata.forEach(update::set); + update.set("updatedAt", java.time.LocalDateTime.now().toString()).set("rankingDirty", true); + return applyUpdate(snapshot, update); + } + private boolean applyUpdate(Snapshot snapshot, Update update) { return applyUpdate(snapshot, update, null); } + private boolean applyUpdate(Snapshot snapshot, Update update, Object originGuard) { + var entity = mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var mapped = new UpdateMapper(mongo.getConverter()).getMappedObject(update.getUpdateObject(), entity); + Object expression = new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw()))); + if (originGuard != null) expression = new Document("$and", List.of(expression, originGuard)); + var query = new Document("_id", snapshot.raw().get("_id")).append("$expr", expression); + return mongo.getCollection(mongo.getCollectionName(Project.class)).updateOne(query, mapped).getModifiedCount() > 0; + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java index 63d685f1e..3df64acd4 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java @@ -1,16 +1,10 @@ package net.modtale.service.admin.review; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; import net.modtale.exception.ResourceNotFoundException; import net.modtale.mapper.ProjectMapper; import net.modtale.model.dto.admin.AdminAuthorStatsDTO; import net.modtale.model.dto.admin.AdminProjectReviewDTO; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; import net.modtale.model.project.Project; -import net.modtale.model.project.ScanStatus; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.project.query.ProjectListingQueryService; @@ -23,51 +17,18 @@ public class ProjectReviewQueryService { private final UserRepository userRepository; private final ProjectService projectService; - private final ProjectReviewQueueService projectReviewQueueService; private final ProjectListingQueryService projectListingQueryService; public ProjectReviewQueryService( UserRepository userRepository, ProjectService projectService, - ProjectReviewQueueService projectReviewQueueService, ProjectListingQueryService projectListingQueryService ) { this.userRepository = userRepository; this.projectService = projectService; - this.projectReviewQueueService = projectReviewQueueService; this.projectListingQueryService = projectListingQueryService; } - public List getVerificationQueue() { - return projectReviewQueueService.getVerificationQueue().stream() - .map(ProjectMapper::toVerificationQueueItemDTO) - .filter(Objects::nonNull) - .sorted(Comparator - .comparingInt(ProjectReviewQueryService::queuePriority).reversed() - .thenComparing(item -> item.updatedAt() == null ? "" : item.updatedAt())) - .toList(); - } - - private static int queuePriority(AdminVerificationQueueItemDTO item) { - if (item.pendingVersion() == null || item.pendingVersion().scan() == null) return 2_000; - var scan = item.pendingVersion().scan(); - String verdict = scan.verdict() == null ? "" : scan.verdict().toUpperCase(Locale.ROOT); - int riskScore = scan.riskScore(); - - if ("BLOCK".equals(verdict) || scan.status() == ScanStatus.INFECTED) { - return 8_000 + riskScore + scan.newIssueCount() * 10 + scan.escalatedIssueCount() * 15; - } - if (scan.newIssueCount() > 0 || scan.escalatedIssueCount() > 0) { - return 6_000 + riskScore + scan.newIssueCount() * 6 + scan.escalatedIssueCount() * 10; - } - if ("REVIEW".equals(verdict) - || scan.status() == ScanStatus.SUSPICIOUS - || scan.status() == ScanStatus.FLAGGED) { - return 4_000 + riskScore; - } - return 2_000 + riskScore; - } - public AdminProjectReviewDTO getProjectReviewDetails(String id) { Project project = requireProject(id); User author = userRepository.findById(project.getAuthorId()).orElse(null); diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java deleted file mode 100644 index 4792251bb..000000000 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java +++ /dev/null @@ -1,59 +0,0 @@ -package net.modtale.service.admin.review; - -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectStatus; -import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ScanStatus; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.stereotype.Service; - -@Service -public class ProjectReviewQueueService { - - private final MongoTemplate mongoTemplate; - - public ProjectReviewQueueService(MongoTemplate mongoTemplate) { - this.mongoTemplate = mongoTemplate; - } - - public List getVerificationQueue() { - Criteria reviewCandidate = new Criteria().orOperator( - Criteria.where("status").is(ProjectStatus.PENDING), - new Criteria().andOperator( - Criteria.where("status").is(ProjectStatus.PUBLISHED), - Criteria.where("versions").elemMatch( - Criteria.where("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) - ) - ) - ); - Criteria noVersionIsScanning = Criteria.where("versions").not().elemMatch( - Criteria.where("scanResult.status").is(ScanStatus.SCANNING) - ); - Query query = new Query(new Criteria().andOperator(reviewCandidate, noVersionIsScanning)); - - query.fields() - .include("id") - .include("title") - .include("description") - .include("author") - .include("imageUrl") - .include("classification") - .include("status") - .include("updatedAt") - .include("versions.id") - .include("versions.versionNumber") - .include("versions.changelog") - .include("versions.reviewStatus") - .include("versions.scanResult.status") - .include("versions.scanResult.verdict") - .include("versions.scanResult.riskScore") - .include("versions.scanResult.knownIssueCount") - .include("versions.scanResult.newIssueCount") - .include("versions.scanResult.escalatedIssueCount"); - - return mongoTemplate.find(query, Project.class); - } -} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java new file mode 100644 index 000000000..226c22a21 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java @@ -0,0 +1,33 @@ +package net.modtale.service.admin.review; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import net.modtale.model.project.Project; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +public final class ProjectReviewSnapshot { + private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private ProjectReviewSnapshot() {} + public static String token(Project project) { + try { + @SuppressWarnings("unchecked") Map fields = MAPPER.convertValue(project, Map.class); + for (String operational : List.of("downloadCount", "favoriteCount", "downloads7d", "downloads30d", "downloads90d", + "trendScore", "relevanceScore", "popularScore", "trendingRank", "popularRank", "relevanceRank", + "rankingDirty", "lastTrendingNotification", "comments", "canEdit", "owner")) fields.remove(operational); + fields.put("versions", project.getVersions() == null ? List.of() + : project.getVersions().stream().map(VersionReviewSnapshot::token).toList()); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(MAPPER.writeValueAsBytes(fields))); + } catch (Exception invalid) { throw new IllegalStateException("Could not bind the project review", invalid); } + } + public static void requireCurrent(Project project, String expected) { + if (expected == null || !expected.equals(token(project))) throw conflict(); + } + public static ResponseStatusException conflict() { + return new ResponseStatusException(HttpStatus.CONFLICT, + "This project changed after the review was opened. Refresh its evidence before deciding."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java index d48ab0538..202c1c014 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java @@ -6,7 +6,6 @@ import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.service.analytics.ScoringService; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.lifecycle.LifecycleService; @@ -17,7 +16,8 @@ @Service public class ProjectReviewTransitionService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence projectReviewPersistence; + private final VersionReviewPersistence reviewPersistence; private final ProjectService projectService; private final LifecycleService lifecycleService; private final ScoringService scoringService; @@ -25,14 +25,16 @@ public class ProjectReviewTransitionService { private final ProjectVersionAccessService projectVersionAccessService; public ProjectReviewTransitionService( - ProjectRepository projectRepository, ProjectService projectService, LifecycleService lifecycleService, ScoringService scoringService, SecurityIssueAnalysisService securityIssueAnalysisService, - ProjectVersionAccessService projectVersionAccessService + ProjectVersionAccessService projectVersionAccessService, + VersionReviewPersistence reviewPersistence, + ProjectReviewPersistence projectReviewPersistence ) { - this.projectRepository = projectRepository; + this.projectReviewPersistence = projectReviewPersistence; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.lifecycleService = lifecycleService; this.scoringService = scoringService; @@ -40,41 +42,53 @@ public ProjectReviewTransitionService( this.projectVersionAccessService = projectVersionAccessService; } - public void publishProject(User adminUser, String id) { - lifecycleService.publishProject(id, adminUser); + public void publishProject(User adminUser, String id, String reviewToken, String versionId) { + lifecycleService.publishProject(id, adminUser, reviewToken, versionId); } - public VersionReviewDecision approveVersion(String id, String versionId) { + public VersionReviewDecision approveVersion(String id, String versionId, String reviewToken) { Project project = requireProject(id); ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new ResourceNotFoundException("Version not found.")); + VersionReviewSnapshot.requireCurrent(version,reviewToken); + var snapshot=reviewPersistence.capture(id,versionId,reviewToken); version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); version.setRejectionReason(null); version.setScheduledPublishDate(null); - securityIssueAnalysisService.pruneApprovedScanResults(project); + version.setSecurityApprovalProjectId(project.getId()); + securityIssueAnalysisService.markIssuesAcceptedForApprovedVersion(version); project.setUpdatedAt(LocalDateTime.now().toString()); - projectRepository.save(project); + if(!reviewPersistence.apply(snapshot,version)) throw VersionReviewPersistence.conflict(); projectService.evictProjectCache(project); return new VersionReviewDecision(project, version, null); } - public VersionReviewDecision rejectVersion(String id, String versionId, String reason) { + public VersionReviewDecision rejectVersion(String id, String versionId, String reason, String reviewToken) { Project project = requireProject(id); ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new ResourceNotFoundException("Version not found.")); + VersionReviewSnapshot.requireCurrent(version,reviewToken); + var snapshot=reviewPersistence.capture(id,versionId,reviewToken); version.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); version.setRejectionReason(reason); + version.setApprovedSecurityEvidence(null); + version.setApprovedSecurityContextSha256(null); + version.setSecurityApprovedAt(0); + version.setApprovedIssueBaselines(null); version.setScheduledPublishDate(null); - projectRepository.save(project); + if(!reviewPersistence.apply(snapshot,version)) throw VersionReviewPersistence.conflict(); projectService.evictProjectCache(project); return new VersionReviewDecision(project, version, reason); } - public ProjectRejectionDecision rejectProject(String id, String reason) { - Project project = requireProject(id); + public ProjectRejectionDecision rejectProject(String id, String reason, String reviewToken) { + var snapshot = projectReviewPersistence.capture(id, reviewToken); + Project project = snapshot.project(); + if (project.getStatus() != ProjectStatus.PENDING) throw ProjectReviewSnapshot.conflict(); project.setStatus(ProjectStatus.DRAFT); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!projectReviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); return new ProjectRejectionDecision(project, reason); } diff --git a/backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java b/backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java new file mode 100644 index 000000000..656828915 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java @@ -0,0 +1,93 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.*; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Exact stored bytes for a unique version. Capture is evidence, not permission to repair. */ +public final class RawReviewSnapshotReader { + public record Captured(Object projectId,int versionIndex,String versionId,byte[] versionBytes,String sha256) { + public Captured {versionBytes=versionBytes.clone();} + @Override public byte[] versionBytes(){return versionBytes.clone();} + @Override public String toString(){return "CapturedReviewSnapshot["+sha256+"]";} + public ReviewSnapshotArchive.Snapshot forArchive(String id,String actor,ReviewSnapshotArchive.Action action,long createdAt,long expiresAt) { + return new ReviewSnapshotArchive.Snapshot(id,projectId,versionIndex,actor,action,createdAt,expiresAt,versionBytes); + } + } + private final MongoCollection projects; + public RawReviewSnapshotReader(MongoTemplate mongo) { + projects=mongo.getCollection("projects").withDocumentClass(RawBsonDocument.class).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Captured capture(Object projectId,int versionIndex,String versionId) {return capture(null,projectId,versionIndex,versionId);} + public Captured capture(com.mongodb.client.ClientSession session,Object projectId,int versionIndex,String versionId) { + if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128 + && java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(s)) || versionIndex<0 || versionIndex>16*1024*1024 + || versionId==null || versionId.isBlank() || versionId.length()>128 || versionId.chars().anyMatch(Character::isISOControl) + || !java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(versionId))throw new IllegalArgumentException("Invalid review snapshot position"); + var query=new Document("_id",projectId); + var timed=ReviewRepairIo.collection(projects,session); + var root=(session==null?timed.find(query):timed.find(session,query)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + if(root==null)throw conflict(); + var buffer=root.getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes); + if(bytes.length>ReviewSnapshotArchive.MAX_BYTES)throw conflict(); + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5);byte[] selected=null;int matches=0,arrays=0,rootIds=0; + try(var reader=new BsonBinaryReader(ByteBuffer.wrap(bytes))) { + reader.readStartDocument(); + while(reader.readBsonType()!=BsonType.END_OF_DOCUMENT) { + checkDeadline(deadline);String field=reader.readName(); + if(field.equals("_id")) { + rootIds++;Object found=reader.getCurrentBsonType()==BsonType.OBJECT_ID?reader.readObjectId():reader.getCurrentBsonType()==BsonType.STRING?reader.readString():null; + if(!projectId.equals(found))throw conflict(); + } else if(field.equals("versions")) { + if(++arrays!=1 || reader.getCurrentBsonType()!=BsonType.ARRAY)throw conflict(); + reader.readStartArray();int index=0; + while(reader.readBsonType()!=BsonType.END_OF_DOCUMENT) { + checkDeadline(deadline);int start=reader.getBsonInput().getPosition();boolean document=reader.getCurrentBsonType()==BsonType.DOCUMENT; + reader.skipValue();int end=reader.getBsonInput().getPosition(); + if(document) { + String id=versionId(bytes,start,end,deadline); + if(versionId.equals(id))matches++; + if(index==versionIndex) { + if(!versionId.equals(id))throw conflict(); + selected=Arrays.copyOfRange(bytes,start,end); + } + } else if(index==versionIndex)throw conflict(); + index++; + } + reader.readEndArray(); + } else reader.skipValue(); + } + reader.readEndDocument(); + } catch(RuntimeException malformed) {throw conflict();} + if(rootIds!=1 || arrays!=1 || matches!=1 || selected==null)throw conflict(); + try {return new Captured(projectId,versionIndex,versionId,selected,HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(selected)));} + catch(java.security.NoSuchAlgorithmException unavailable){throw new IllegalStateException(unavailable);} + } + public boolean isCurrent(Captured snapshot) { + try {var current=capture(snapshot.projectId(),snapshot.versionIndex(),snapshot.versionId());return Arrays.equals(snapshot.versionBytes(),current.versionBytes());} + catch(IllegalStateException changed){return false;} + } + private static String versionId(byte[] bytes,int start,int end,long deadline) { + String id=null;int count=0; + try(var reader=new BsonBinaryReader(ByteBuffer.wrap(bytes,start,end-start).slice())) { + reader.readStartDocument(); + while(reader.readBsonType()!=BsonType.END_OF_DOCUMENT) { + checkDeadline(deadline);String field=reader.readName(); + if(field.equals("_id")) {count++;if(reader.getCurrentBsonType()==BsonType.STRING)id=reader.readString();else reader.skipValue();} + else reader.skipValue(); + } + reader.readEndDocument(); + } + // Duplicate identity fields are ambiguous even if they happen to contain the same value. + if(count>1)throw conflict();return id; + } + private static void checkDeadline(long deadline){if(System.nanoTime()>deadline)throw conflict();} + private static IllegalStateException conflict(){return new IllegalStateException("Review snapshot changed, is ambiguous, or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java new file mode 100644 index 000000000..576c45c16 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java @@ -0,0 +1,87 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import net.modtale.service.user.account.AccountService; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.function.BooleanSupplier; + +/** Account-bound operations; all work shares the existing repair admission and I/O budget. */ +public final class ReviewCancellationAccess { + public record Check(String id, ReviewOrphanCancellationJournal.Prepared original) {} + private final AccountService accounts; + private final ReviewRepairWorkflow workflow; + private final ReviewOrphanCancellationJournal journal; + private final ReviewOrphanCancellationExecutor executor; + private final ReviewCancellationReconciler reconciler; + + public ReviewCancellationAccess(AccountService accounts, ReviewRepairWorkflow workflow, + ReviewOrphanCancellationJournal journal, ReviewOrphanCancellationExecutor executor, + ReviewCancellationReconciler reconciler) { + this.accounts=accounts;this.workflow=workflow;this.journal=journal;this.executor=executor;this.reconciler=reconciler; + } + public record Capability(String action) {} + public Capability capability() { + var authority=authority(); + return workflow.call(allowed->new Capability("CANCEL_ORIGINAL_REVIEW"),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Preview preview(String isolationId) { + var authority=authority();uuid(isolationId); + return workflow.call(allowed->journal.preview(isolationId,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Prepared prepare(String isolationId) { + var authority=authority();uuid(isolationId); + return workflow.call(allowed->journal.prepare(isolationId,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Receipt recover(String isolationId) { + var authority=authority();uuid(isolationId); + return workflow.call(allowed->journal.recover(isolationId,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationExecutor.Execution execute(ReviewOrphanCancellationJournal.Prepared original) { + var authority=authority();validate(original); + return workflow.call(allowed->executor.execute(original,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Receipt receipt(ReviewOrphanCancellationJournal.Prepared original) { + var authority=authority();validate(original); + return workflow.call(allowed->journal.receipt(original,authority.actor(),allowed),authority.allowed()); + } + public ReviewCancellationReconciler.Receipt check(Check request) { + var authority=authority();validate(request); + return workflow.call(allowed->reconciler.check(request.id(),request.original(),authority.actor(),allowed),authority.allowed()); + } + public ReviewCancellationReconciler.Receipt checkReceipt(Check request) { + var authority=authority();validate(request); + return workflow.call(allowed->reconciler.receipt(request.id(),request.original(),authority.actor(),allowed),authority.allowed()); + } + public record History(ReviewOrphanCancellationJournal.Prepared original,String cursor,int limit) {} + public ReviewObservationReader.Page history(History request) { + var authority=authority();if(request==null)throw invalid();validate(request.original()); + if(request.limit()<1 || request.limit()>25)throw invalid(); + if(request.cursor()!=null) { + String prefix="c1."+request.original().isolationId()+"."; + if(!request.cursor().startsWith(prefix))throw invalid();uuid(request.cursor().substring(prefix.length())); + } + return workflow.call(allowed->reconciler.history(request.original(),authority.actor(),request.cursor(),request.limit(),allowed),authority.allowed()); + } + private record Authority(String actor,BooleanSupplier allowed) {} + private Authority authority() { + User user=current();if(!allowed(user))throw new SecurityException("Review cancellation is not permitted"); + String actor=user.getId(); + return new Authority(actor,()->{User refreshed=current();return allowed(refreshed)&&actor.equals(refreshed.getId());}); + } + private User current(){return accounts.getCurrentUser(SecurityContextHolder.getContext().getAuthentication());} + private static boolean allowed(User user) { + return user!=null && !user.isDeleted() && user.getId()!=null && !user.getId().isBlank() + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_REVIEW_READ) + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_VERSION_RESCAN); + } + private static void validate(Check request){if(request==null)throw invalid();uuid(request.id());validate(request.original());} + private static void validate(ReviewOrphanCancellationJournal.Prepared original) { + if(original==null)throw invalid();uuid(original.isolationId()); + if(original.targetSha256()==null || !original.targetSha256().matches("[0-9a-f]{64}") + || original.createdAt()<=0 || original.expiresAt()<=original.createdAt() + || original.expiresAt()-original.createdAt()>120000)throw invalid(); + } + private static void uuid(String id){if(id==null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid();} + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review cancellation request");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java new file mode 100644 index 000000000..fab5a8fa1 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java @@ -0,0 +1,30 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import net.modtale.service.security.scan.RemoteReviewClient; +import net.modtale.service.user.account.AccountService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="app.warden.repair.cancellation.enabled",havingValue="true") +public class ReviewCancellationConfiguration { + @Bean ReviewOrphanTargetResolver reviewOrphanTargetResolver(MongoTemplate mongo,ReviewSnapshotArchive archive,ReviewIsolationExecutor isolation) { + return new ReviewOrphanTargetResolver(mongo,archive,isolation); + } + @Bean ReviewOrphanCancellationJournal reviewOrphanCancellationJournal(MongoTemplate mongo,ReviewOrphanTargetResolver resolver,ReviewSnapshotArchive archive) { + return new ReviewOrphanCancellationJournal(mongo,resolver,archive,Clock.systemUTC(),60000); + } + @Bean ReviewOrphanCancellationExecutor reviewOrphanCancellationExecutor(ReviewOrphanCancellationJournal journal,RemoteReviewClient client,AppReviewRepairProperties properties) { + return new ReviewOrphanCancellationExecutor(journal,client,properties.concurrency(),Duration.ofSeconds(30)); + } + @Bean(initMethod="initializeDiscovery") ReviewCancellationReconciler reviewCancellationReconciler(MongoTemplate mongo,ReviewOrphanCancellationJournal journal,RemoteReviewClient client,AppReviewRepairProperties properties) { + return new ReviewCancellationReconciler(mongo,journal,client,properties.concurrency(),Duration.ofSeconds(30)); + } + @Bean ReviewCancellationAccess reviewCancellationAccess(AccountService accounts,ReviewRepairWorkflow workflow,ReviewOrphanCancellationJournal journal, + ReviewOrphanCancellationExecutor executor,ReviewCancellationReconciler reconciler) { + return new ReviewCancellationAccess(accounts,workflow,journal,executor,reconciler); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java new file mode 100644 index 000000000..ec0800c0e --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java @@ -0,0 +1,44 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** Separate status observations. No cancellation, replacement or overwrite of the original outcome. */ +public final class ReviewCancellationReconciler implements AutoCloseable { + public static final String COLLECTION="review_cancellation_observations"; + public record Receipt(String id,ReviewOrphanCancellationJournal.Prepared original,String state, + ReviewOrphanCancellationJournal.Observation observation,Long receivedAt) {} + private final ReviewOrphanCancellationJournal journal; + private final ReviewObservationReader reader; + private final ReviewRemoteStatusObserver observer; + public ReviewCancellationReconciler(MongoTemplate mongo,ReviewOrphanCancellationJournal journal,RemoteReviewClient client,int concurrency,Duration budget) { + this.journal=Objects.requireNonNull(journal);reader=new ReviewObservationReader(mongo); + observer=new ReviewRemoteStatusObserver(mongo,COLLECTION,client,concurrency,budget); + } + public void initializeDiscovery(){reader.initialize();} + public ReviewObservationReader.Page history(ReviewOrphanCancellationJournal.Prepared original,String actor,String cursor,int limit,BooleanSupplier permitted) { + permission(permitted);journal.reconciliationTarget(original,actor,permitted); + var page=reader.page(original,actor,cursor,limit);permission(permitted);return page; + } + public Receipt check(String id,ReviewOrphanCancellationJournal.Prepared original,String actor,BooleanSupplier permitted) { + return receipt(original,observer.check(id,allowed->target(original,actor,allowed),permitted)); + } + public Receipt receipt(String id,ReviewOrphanCancellationJournal.Prepared original,String actor,BooleanSupplier permitted) { + return receipt(original,observer.receipt(id,allowed->target(original,actor,allowed),permitted)); + } + private ReviewRemoteStatusObserver.Target target(ReviewOrphanCancellationJournal.Prepared original,String actor,BooleanSupplier permitted) { + var target=journal.reconciliationTarget(original,actor,permitted); + var intent=new Document("actor",actor).append("isolationId",original.isolationId()).append("targetSha256",original.targetSha256()) + .append("createdAt",original.createdAt()).append("expiresAt",original.expiresAt()); + return new ReviewRemoteStatusObserver.Target(intent,target.binding()); + } + private static Receipt receipt(ReviewOrphanCancellationJournal.Prepared original,ReviewRemoteStatusObserver.Receipt result) { + return new Receipt(result.id(),original,result.state(),result.observation(),result.receivedAt()); + } + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Reconciliation is not permitted");} + @Override public void close(){observer.close();} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java new file mode 100644 index 000000000..6c545370d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java @@ -0,0 +1,165 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import com.mongodb.client.model.*; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ArtifactReviewContext; +import org.bson.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Isolates broken local review state; never approves or starts/cancels a remote review. */ +public final class ReviewIsolationExecutor { + public record Result(String state,String afterSha256) {} + private final MongoTemplate mongo; + private final ReviewSnapshotArchive archive; + private final RawReviewSnapshotReader reader; + private final ReviewRepairJournal journal; + private final ReviewRepairOperationReader operationReader; + private final MongoCollection projects,operations; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT) + .readPreference(ReadPreference.primary()).writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + public ReviewIsolationExecutor(MongoTemplate mongo,ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,ReviewRepairJournal journal) { + this.mongo=mongo;this.archive=archive;this.reader=reader;this.journal=journal;operationReader=new ReviewRepairOperationReader(mongo); + projects=mongo.getCollection("projects");operations=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public void initializeDiscovery(){operationReader.initialize();} + public ReviewRepairOperationReader.Page operations(String actor,String cursor,int limit){return operationReader.page(actor,cursor,limit);} + public record Recovered(ReviewRepairPreparation.Prepared prepared,Receipt receipt) {} + public Recovered recover(String id,String actor) { + var source=archive.load(id); + if(source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW || !source.actorId().equals(actor))throw new SecurityException("Repair recovery is not permitted"); + var prepared=new ReviewRepairPreparation.Prepared(source.id(),sourceDigest(source),source.createdAt(),source.expiresAt()); + return new Recovered(prepared,receipt(source,prepared)); + } + public Receipt closeExpired(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var source=verifiedSource(prepared,actor); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw new IllegalStateException("Review isolation requires transaction support"); + try {journal.closeExpired(prepared,actor,permitted);} + catch(MongoException unknown) { + // An acknowledgement loss does not justify another write. Only read back the receipt. + try(var cleanup=ReviewRepairIo.cleanup()){return receipt(source,prepared);} + } + return receipt(source,prepared); + } + public boolean eligible(RawReviewSnapshotReader.Captured captured) { + var source=captured.forArchive("00000000-0000-0000-0000-000000000000","inspection",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,1,2); + var original=new RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + return fields(original,source)!=null; + } + public record Receipt(Result outcome,Object projectId,int versionIndex,String versionId,String beforeSha256) {} + public Receipt receipt(ReviewRepairPreparation.Prepared prepared,String actor) { + return receipt(verifiedSource(prepared,actor),prepared); + } + private Receipt receipt(ReviewSnapshotArchive.Snapshot source,ReviewRepairPreparation.Prepared prepared) { + String versionId=new RawBsonDocument(source.versionBytes()).getString("_id").getValue(); + return new Receipt(outcome(prepared,source.actorId()),source.projectId(),source.versionIndex(),versionId,prepared.sha256()); + } + private ReviewSnapshotArchive.Snapshot verifiedSource(ReviewRepairPreparation.Prepared prepared,String actor) { + var source=archive.load(prepared.id()); + if(source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW || !source.actorId().equals(actor))throw new SecurityException("Repair execution is not permitted"); + if(!sourceDigest(source).equals(prepared.sha256()) || source.createdAt()!=prepared.createdAt() || source.expiresAt()!=prepared.expiresAt())throw new IllegalStateException("Repair intent changed"); + return source; + } + private static String sourceDigest(ReviewSnapshotArchive.Snapshot source) { + try {return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(source.versionBytes()));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + public Result execute(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var source=verifiedSource(prepared,actor); + var original=new RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + Document fields=fields(original,source);if(fields==null)return new Result("INELIGIBLE",null); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw new IllegalStateException("Review isolation requires transaction support"); + permission(permitted); + var claim=journal.claim(prepared,actor,ReviewSnapshotArchive.Action.ISOLATE_REVIEW,permitted); + if(claim==null)return outcome(prepared,actor); + boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(permitted); + var op=ReviewRepairIo.collection(operations,session).find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(op==null)throw new IllegalStateException("Repair claim changed"); + var current=reader.capture(session,source.projectId(),source.versionIndex(),original.getString("_id")); + String result;String after=null; + if(!Arrays.equals(source.versionBytes(),current.versionBytes()))result="NOT_APPLIED"; + else { + String prefix="versions."+source.versionIndex()+".";var set=new Document();fields.forEach((key,value)->set.put(prefix+key,value)); + var conditions=new ArrayList();conditions.add(new Document("$gte",List.of("$$NOW",new Date(source.createdAt()))));conditions.add(new Document("$lt",List.of("$$NOW",new Date(source.expiresAt())))); + var scan=original.get("scanResult",Document.class);var poll=scan.get("remotePoll"); + if(poll instanceof Document p && p.get("leaseUntil") instanceof Date lease)conditions.add(new Document("$gte",List.of("$$NOW",lease))); + permission(permitted); + long changed=ReviewRepairIo.collection(projects,session).updateOne(session,new Document("_id",source.projectId()).append("$expr",new Document("$and",conditions)), + new Document("$set",set).append("$currentDate",new Document(prefix+"reviewIsolation.isolatedAt",true)),new UpdateOptions().collation(BINARY)).getModifiedCount(); + if(changed==1) {after=reader.capture(session,source.projectId(),source.versionIndex(),original.getString("_id")).sha256();result="APPLIED";} + else result="NOT_APPLIED"; + } + var outcome=new Document("state",result).append("afterSha256",after); + if(ReviewRepairIo.collection(operations,session).updateOne(session,op,List.of(new Document("$set",outcome.append("finishedAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1)throw new IllegalStateException("Repair claim changed"); + permission(permitted);commitAttempted=true;session.commitTransaction();return new Result(result,after); + } catch(RuntimeException failure) { + if(!commitAttempted)try{session.abortTransaction();}catch(RuntimeException ignored){} + throw failure; + } + } catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + // Conservative bookkeeping for an already-owned operation remains necessary after permission loss. + if(uncertain instanceof SecurityException) {try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try {var observed=outcome(prepared,actor);if(!observed.state().equals("UNKNOWN"))return observed;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){} + return new Result("UNKNOWN",null); + } + } + } + private Result outcome(ReviewRepairPreparation.Prepared prepared,String actor) { + var record=ReviewRepairIo.collection(operations).find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(record==null || !actor.equals(record.get("actor")) || !prepared.sha256().equals(record.get("sha256")) + || !Long.valueOf(prepared.createdAt()).equals(record.get("createdAt")) || !Long.valueOf(prepared.expiresAt()).equals(record.get("expiresAt")) + || !"ISOLATE_REVIEW".equals(record.get("action")))return new Result("UNKNOWN",null); + if(!(record.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(record.get("state")) && record.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + if("APPLIED".equals(record.get("state")) && record.get("afterSha256") instanceof String sha && sha.matches("[0-9a-f]{64}"))return new Result("APPLIED",sha); + return new Result("UNKNOWN",null); + } + private Document fields(Document original,ReviewSnapshotArchive.Snapshot source) { + try { + var encoded=new RawBsonDocument(original,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[encoded.remaining()];encoded.get(bytes); + if(!Arrays.equals(bytes,source.versionBytes()) || !(original.get("_id") instanceof String) || !"PENDING".equals(original.get("reviewStatus")) + || !(original.get("scanResult") instanceof Document scan) || !"SCANNING".equals(scan.get("status")))return null; + if(original.containsKey("reviewIsolation") && !(original.get("reviewIsolation") instanceof Document))return null; + var changed=new Document(scan);changed.put("remotePoll",null);changed.put("remoteStatus",null); + boolean broken=scan.containsKey("remotePoll") && invalidPoll(scan.get("remotePoll")); + String state=scan.getString("scanState");broken|=!Set.of("QUEUED","SCANNING","REMOTE_REVIEW").contains(Objects.toString(state,"")); + RemoteReviewBinding binding=null; + if(scan.get("remoteReview")!=null) { + try{binding=mongo.getConverter().read(RemoteReviewBinding.class,(Document)scan.get("remoteReview"));} + catch(RuntimeException invalid){broken=true;changed.put("remoteReview",null);} + } else if("REMOTE_REVIEW".equals(state))broken=true; + changed.put("status","FAILED");changed.put("scanState","REMOTE_ISOLATED");changed.put("verdict","BLOCK".equals(scan.get("verdict"))?"BLOCK":"REVIEW"); + for(String field:List.of("securityEvidence","reviewedContextSha256","reusedReviewVersion","reusedReviewOrigins"))changed.put(field,null); + changed.put("artifactVerified",false);changed.put("holdUntilTimestamp",0L);changed.put("reusedReviewApprovedAt",0L); + var patch=new Document("scanResult",changed).append("scheduledPublishDate",null).append("securityApprovedAt",0L); + for(String field:List.of("securityApprovalProjectId","approvedSecurityEvidence","approvedSecurityContextSha256","approvedReviewOrigins","approvedFindingReviewHead"))patch.put(field,null); + var candidate=new Document(original);candidate.putAll(patch);var typed=mongo.getConverter().read(ProjectVersion.class,candidate); + if(binding!=null)broken|=!source.projectId().toString().equals(binding.projectId()) || !typed.getId().equals(binding.versionId()) + || !Objects.equals(typed.getHash(),binding.artifactSha256()) || !Objects.equals(typed.getFileUrl(),binding.filePath()) + || !Objects.equals(ArtifactReviewContext.automaticallyReviewableFingerprint(typed),binding.contextSha256()) + || !Objects.equals(typed.getScanResult().getScanRequestId(),binding.requestId()) || typed.getScanResult().getScanAttempt()!=binding.attempt() + || typed.getScanResult().isManualRescan()!=binding.manualRescan() || !"REMOTE_REVIEW".equals(state); + if(!broken)return null; + patch.put("reviewIsolation.operationId",source.id());patch.put("reviewIsolation.actorId",source.actorId()); + patch.put("reviewIsolation.beforeSha256",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(source.versionBytes()))); + return patch; + } catch(Exception invalid){return null;} + } + private static boolean invalidPoll(Object value) { + return !(value instanceof Document p) || p.size()!=3 || !(p.get("leaseUntil") instanceof Date) || !(p.get("nextPollAt") instanceof Date) + || !p.containsKey("token") || p.get("token")!=null && (!(p.get("token") instanceof String token) || !token.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")); + } + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Review isolation is not permitted");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewObservationReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewObservationReader.java new file mode 100644 index 000000000..317fa13bb --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewObservationReader.java @@ -0,0 +1,48 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Bounded discovery references; every selected observation still needs authenticated receipt recovery. */ +public final class ReviewObservationReader { + public record Item(String id,String recordedState) {} + public record Page(List items,String nextCursor,String order) {} + public static final String INDEX="review_observation_actor_isolation_id"; + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + private static final List STATES=List.of("RESERVED","READING","OBSERVED","UNKNOWN"); + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection observations; + public ReviewObservationReader(MongoTemplate mongo) { + observations=mongo.getCollection(ReviewCancellationReconciler.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public void initialize() { + observations.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).createIndex(new Document("intent.actor",1).append("intent.isolationId",1).append("_id",1),new IndexOptions().name(INDEX).collation(BINARY)); + } + Page page(ReviewOrphanCancellationJournal.Prepared original,String actor,String cursor,int limit) { + if(original==null || actor==null || actor.isBlank() || actor.length()>256 || limit<1 || limit>25)throw new IllegalArgumentException("Invalid observation page"); + String prefix="c1."+original.isolationId()+".";String after=null; + if(cursor!=null){if(!cursor.startsWith(prefix) || !cursor.substring(prefix.length()).matches(UUID))throw new IllegalArgumentException("Invalid observation cursor");after=cursor.substring(prefix.length());} + var id=new Document("$type","string").append("$regex","^"+UUID+"$");if(after!=null)id.append("$gt",after); + var types=new ArrayList(); + for(String field:List.of("actor","isolationId","targetSha256"))types.add(new Document("$eq",List.of(new Document("$type","$intent."+field),"string"))); + for(String field:List.of("createdAt","expiresAt"))types.add(new Document("$eq",List.of(new Document("$type","$intent."+field),"long"))); + types.add(new Document("$eq",List.of(new Document("$type","$intent"),"object"))); + var query=new Document("intent.actor",actor).append("intent.isolationId",original.isolationId()).append("intent.targetSha256",original.targetSha256()) + .append("intent.createdAt",original.createdAt()).append("intent.expiresAt",original.expiresAt()).append("_id",id).append("$expr",new Document("$and",types)); + var state=new Document("$cond",List.of(new Document("$in",List.of("$state",STATES)),"$state","UNKNOWN")); + var pipeline=List.of(new Document("$match",query),new Document("$sort",new Document("_id",1)),new Document("$limit",limit+1),new Document("$project",new Document("_id",1).append("recordedState",state))); + var records=ReviewRepairIo.collection(observations).aggregate(pipeline).hintString(INDEX).collation(BINARY).allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).into(new ArrayList<>()); + var items=new ArrayList(); + for(var record:records.subList(0,Math.min(limit,records.size()))) { + String key=record.getString("_id"),recordedState=record.getString("recordedState"); + if(key==null || !key.matches(UUID) || !STATES.contains(recordedState))throw new IllegalStateException("Invalid observation reference"); + items.add(new Item(key,recordedState)); + } + return new Page(List.copyOf(items),records.size()>limit?prefix+items.getLast().id():null,"OBSERVATION_ID"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java new file mode 100644 index 000000000..9baf09d4c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java @@ -0,0 +1,17 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewDiscovery; + +/** Origin inventory positions cannot be reused as scan-diagnostic positions. */ +public final class ReviewOriginCursor { + private ReviewOriginCursor() {} + public static String encode(RemoteReviewDiscovery.Cursor cursor) { + String encoded=ReviewStateDiagnosticCursor.encode(cursor); + return encoded==null?null:"o1."+encoded.substring(3); + } + public static RemoteReviewDiscovery.Cursor decode(String token) { + if(token==null)return null; + if(token.length()>810 || !token.startsWith("o1."))throw new IllegalArgumentException("Invalid origin cursor"); + return ReviewStateDiagnosticCursor.decode("d1."+token.substring(3)); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java new file mode 100644 index 000000000..c871417d0 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java @@ -0,0 +1,75 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.Project; +import net.modtale.model.project.RemoteReviewOrigin; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Read-only inventory of retained origin fields, not proof of a job's origin or permission to reconcile it. */ +@org.springframework.stereotype.Service +public final class ReviewOriginInventory { + public enum OriginState { MISSING, INVALID, RECORDED, UNREADABLE_BINDING } + public record Item(Object projectId,int versionIndex,String versionId,boolean ambiguousVersion, + String requestId,String jobId,OriginState originState) {} + public record Page(List items,RemoteReviewDiscovery.Cursor next,int examined) { + public Page {items=List.copyOf(items);} + } + private final MongoCollection projects; + public ReviewOriginInventory(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(RemoteReviewDiscovery.Cursor cursor,int limit) { + if(limit<1 || limit>64)throw new IllegalArgumentException("Invalid origin inventory page size"); + var terms=new ArrayList(); + terms.add(new Document("$cond",List.of(eq(type("$_id"),"string"),new Document("$and",List.of( + new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true))); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))) + .append("versions",new Document("$elemMatch",new Document("scanResult.remoteReview",new Document("$exists",true).append("$ne",null)))) + .append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(eq("$_id",literal(cursor.projectId())),cursor.offset(),0)); + var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + String binding="$$v.scanResult.remoteReview",origin=binding+".origin"; + var view=new Document("bindingType",type(binding)).append("versionId",text("$$v._id",128)) + .append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","sibling").append("cond",eq("$$sibling._id","$$v._id"))))) + .append("requestId",text(binding+".requestId",36)).append("jobId",text(binding+".jobId",36)) + .append("originType",type(origin)).append("deployment",text(origin+".deploymentId",36)).append("caller",text(origin+".callerScope",64)); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",versions)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(versions,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=projects.withTimeout(5000,TimeUnit.MILLISECONDS).aggregate(pipeline).collation(Collation.builder().locale("simple").build()) + .allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()) {root=rows.hasNext()?rows.next():null;} + if(root==null)return new Page(List.of(),null,0); + var items=new ArrayList();var rows=root.getList("items",Document.class);int index=root.getInteger("offset"); + for(var row:rows) { + String bindingType=row.getString("bindingType"); + if(!Set.of("missing","null").contains(bindingType)) { + String version=row.getString("versionId");boolean valid=version!=null && !version.isBlank() && version.length()<=128 && version.chars().noneMatch(Character::isISOControl); + boolean object="object".equals(bindingType); + items.add(new Item(root.get("_id"),index,valid?version:null,!valid || row.getInteger("idCount",0)!=1, + object?uuid(row.getString("requestId")):null,object?uuid(row.getString("jobId")):null,object?originState(row):OriginState.UNREADABLE_BINDING)); + } + index++; + } + var next=index2 || budget==null || budget.compareTo(Duration.ofSeconds(1))<0 || budget.compareTo(Duration.ofSeconds(30))>0)throw new IllegalArgumentException("Invalid cancellation capacity"); + this.journal=Objects.requireNonNull(journal);this.client=Objects.requireNonNull(client);slots=new Semaphore(concurrency);budgetNanos=budget.toNanos(); + } + public Execution execute(ReviewOrphanCancellationJournal.Prepared prepared,String actor,BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Cancellation is not permitted"); + if(closed.get())return new Execution("SHUTDOWN",null); + if(!slots.tryAcquire())return new Execution("BUSY",null); + long started=System.nanoTime();var deadline=new AtomicLong(started+budgetNanos); + LongSupplier remaining=()->Math.min(budgetNanos-(System.nanoTime()-started),deadline.get()-System.nanoTime()); + BooleanSupplier allowed=()->{ + if(closed.get() || Thread.currentThread().isInterrupted() || remaining.getAsLong()<=0)throw new IllegalStateException("Cancellation dispatch stopped"); + return permitted.getAsBoolean(); + }; + ReviewOrphanCancellationJournal.Claim claim=null; + try(var io=ReviewRepairIo.open(remaining)) { + claim=journal.claim(prepared,actor,allowed); + if(claim==null)return result(journal.receipt(prepared,actor,allowed)); + var own=claim;ReviewOrphanCancellationJournal.Observation observation; + try { + var status=client.cancel(own.target().binding(),()->{ + long gateStarted=System.nanoTime();long millis=journal.beginDispatch(own,actor,allowed); + if(millis<=0)return false; + deadline.accumulateAndGet(gateStarted+TimeUnit.MILLISECONDS.toNanos(millis),Math::min); + return allowed.getAsBoolean(); + },remaining); + observation=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",status,null); + }catch(RemoteReviewClient.Unavailable failure) { + int code=failure.status();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==401||code==403||code==429?"UNAVAILABLE":"UNKNOWN"; + observation=new ReviewOrphanCancellationJournal.Observation(kind,null,code); + }catch(RemoteReviewClient.Superseded stopped) {observation=new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,null);} + // Only conservative observation persistence/read-back may use the independent cleanup budget. + try(var cleanup=ReviewRepairIo.cleanup()){return result(journal.record(own,actor,observation,permitted));} + }catch(SecurityException denied){throw denied;} + catch(RuntimeException unknown) { + try(var cleanup=ReviewRepairIo.cleanup()){return result(journal.receipt(prepared,actor,permitted));} + catch(SecurityException denied){throw denied;} + catch(RuntimeException unavailable){return new Execution("UNKNOWN",null);} + }finally{slots.release();} + } + private static Execution result(ReviewOrphanCancellationJournal.Receipt receipt){return new Execution(receipt.state(),receipt);} + @Override public void close(){closed.set(true);} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java new file mode 100644 index 000000000..063b9321f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java @@ -0,0 +1,249 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import net.modtale.service.security.scan.RemoteReviewClient; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** One immutable cancellation intent per isolation, with no remote dispatch or automatic claim recovery. */ +public final class ReviewOrphanCancellationJournal { + public static final String COLLECTION="review_orphan_cancellations"; + public record Prepared(String isolationId,String targetSha256,long createdAt,long expiresAt) {} + public record Claim(Prepared prepared,String token,ReviewOrphanTargetResolver.Target target) {} + public record Observation(String kind,RemoteReviewClient.Status status,Integer httpStatus) { + public Observation { + if(!Set.of("REMOTE_STATUS","NOT_FOUND","CONTEXT_CONFLICT","UNAVAILABLE","UNKNOWN").contains(Objects.toString(kind,"")) + || "REMOTE_STATUS".equals(kind)!=(status!=null) || status!=null && httpStatus!=null + || "NOT_FOUND".equals(kind) && !Integer.valueOf(404).equals(httpStatus) + || "CONTEXT_CONFLICT".equals(kind) && !Integer.valueOf(409).equals(httpStatus) + || "UNAVAILABLE".equals(kind) && !Set.of(401,403,429).contains(httpStatus==null?0:httpStatus) + || httpStatus!=null && (httpStatus<100 || httpStatus>599))throw new IllegalArgumentException("Invalid cancellation observation"); + } + } + public record Receipt(Prepared prepared,String state,Observation observation,Long observedAt) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection operations; + private final ReviewOrphanTargetResolver resolver; + private final ReviewSnapshotArchive archive; + private final Clock clock; + private final long lifetimeMillis; + public ReviewOrphanCancellationJournal(MongoTemplate mongo,ReviewOrphanTargetResolver resolver,ReviewSnapshotArchive archive,Clock clock,long lifetimeMillis) { + if(lifetimeMillis<1000 || lifetimeMillis>120000)throw new IllegalArgumentException("Invalid cancellation lifetime"); + this.resolver=Objects.requireNonNull(resolver);this.archive=Objects.requireNonNull(archive);this.clock=Objects.requireNonNull(clock);this.lifetimeMillis=lifetimeMillis; + operations=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public record Preview(String isolationId,String projectIdType,String projectId,int versionIndex,String versionId, + String requestId,String jobId,String beforeSha256,String targetSha256,String artifactSha256) {} + /** Original archived target only; does not prepare an operation or contact the remote service. */ + public Preview preview(String isolationId,String actor,BooleanSupplier permitted) { + permission(permitted);if(!uuid(isolationId))throw new IllegalArgumentException("Invalid cancellation identity"); + var original=resolver.resolve(isolationId,actor);permission(permitted); + boolean objectId=original.projectId() instanceof org.bson.types.ObjectId; + var binding=original.binding(); + var result=new Preview(original.isolationId(),objectId?"OBJECT_ID":"STRING", + objectId?((org.bson.types.ObjectId)original.projectId()).toHexString():(String)original.projectId(), + original.versionIndex(),binding.versionId(),binding.requestId(),binding.jobId(),original.beforeSha256(), + digest(bytes(target(original))),binding.artifactSha256()); + permission(permitted);return result; + } + public Prepared prepare(String isolationId,String actor,BooleanSupplier permitted) { + permission(permitted);var target=resolver.resolve(isolationId,actor);permission(permitted); + var stored=read(isolationId); + var retained=archive.find(archiveId(isolationId));permission(permitted); + if(retained==null) { + if(stored!=null)throw unavailable(); + long now=clock.millis();var candidate=new ReviewSnapshotArchive.Snapshot(archiveId(isolationId),target.projectId(),target.versionIndex(),actor, + ReviewSnapshotArchive.Action.CANCEL_REMOTE_REVIEW,now,Math.addExact(now,lifetimeMillis),bytes(target(target))); + permission(permitted); + try {retained=archive.retain(candidate);} + catch(RuntimeException uncertain) {retained=archive.find(candidate.id());if(retained==null)throw uncertain;} + } + permission(permitted);validateArchive(retained,actor,target); + if(stored==null) { + var intent=intent(actor,target,retained.createdAt(),retained.expiresAt()); + var candidate=new Document("_id",isolationId).append("intent",intent).append("state","PREPARED"); + permission(permitted); + try {ReviewRepairIo.collection(operations).insertOne(candidate);} + catch(MongoException uncertain) { /* Read back the original immutable intent; never replace it. */ } + stored=read(isolationId); + } + permission(permitted);var prepared=authenticate(stored,actor,target); + if(clock.millis()=prepared.expiresAt())throw unavailable(); + permission(permitted);return prepared; + } + public Claim claim(Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(prepared); + var target=resolver.resolve(prepared.isolationId(),actor);permission(permitted);var stored=read(prepared.isolationId()); + if(!prepared.equals(authenticate(stored,actor,target)))throw unavailable(); + if(!"PREPARED".equals(stored.get("state")))return null; + String token=UUID.randomUUID().toString(); + var predicate=new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",literal(stored))), + new Document("$eq",List.of(new Document("$type","$intent.createdAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.expiresAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.target.versionIndex"),"int")), + new Document("$eq",List.of(new Document("$type","$intent.target.binding.attempt"),"int")), + new Document("$gte",List.of("$$NOW",new Date(prepared.createdAt()))),new Document("$lt",List.of("$$NOW",new Date(prepared.expiresAt()))))); + var query=new Document("_id",prepared.isolationId()).append("$expr",predicate); + permission(permitted); + try { + var armed=ReviewRepairIo.collection(operations).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","EXECUTING").append("token",literal(token)).append("startedAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + permission(permitted); + return owned(armed,stored,token)?new Claim(prepared,token,target):null; + }catch(MongoException uncertain) { + permission(permitted);var armed=read(prepared.isolationId());permission(permitted); + if(owned(armed,stored,token))return new Claim(prepared,token,target); + throw unavailable(); + } + } + /** Returns the database-time budget once, immediately before the HTTP subscription. */ + public long beginDispatch(Claim claim,String actor,BooleanSupplier permitted) { + permission(permitted);var stored=ownedRecord(claim,actor);permission(permitted); + if(!"EXECUTING".equals(stored.get("state")))return 0; + var query=exact(stored).append("$expr",new Document("$and",List.of(exactExpression(stored), + new Document("$gte",List.of("$$NOW",new Date(claim.prepared().createdAt()))),new Document("$lt",List.of("$$NOW",new Date(claim.prepared().expiresAt())))))); + permission(permitted); + // A lost acknowledgement is deliberately not recovered into dispatch authority here. + var dispatched=ReviewRepairIo.collection(operations).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","DISPATCHING").append("dispatchAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + permission(permitted);if(dispatched==null)return 0; + if(!claim.prepared().equals(authenticate(dispatched,actor,claim.target())))throw unavailable(); + return claim.prepared().expiresAt()-dispatched.getDate("dispatchAt").getTime(); + } + public Receipt record(Claim claim,String actor,Observation observation,BooleanSupplier permitted) { + permission(permitted);var stored=ownedRecord(claim,actor);validateObservation(observation,claim.target());permission(permitted); + if(!"DISPATCHING".equals(stored.get("state")))return receipt(claim.prepared(),actor,permitted); + var fields=new Document("state","UNKNOWN".equals(observation.kind())?"UNKNOWN":"OBSERVED") + .append("observation",literal(observationDocument(observation))).append("finishedAt","$$NOW"); + permission(permitted); + try {ReviewRepairIo.collection(operations).updateOne(exact(stored),List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY));} + catch(MongoException uncertain) { /* Receipt read-back decides whether the observation was persisted. */ } + return receipt(claim.prepared(),actor,permitted); + } + public Receipt receipt(Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(prepared); + var recovered=recover(prepared.isolationId(),actor,permitted); + if(!prepared.equals(recovered.prepared()))throw unavailable(); + permission(permitted);return recovered; + } + /** Read-only recovery of the original signed intent, including after expiry. */ + public Receipt recover(String isolationId,String actor,BooleanSupplier permitted) { + permission(permitted);if(!uuid(isolationId))throw new IllegalArgumentException("Invalid cancellation identity"); + var target=resolver.resolve(isolationId,actor);permission(permitted);var stored=read(isolationId); + var prepared=authenticate(stored,actor,target); + String state=stored.getString("state");Observation observation=stored.containsKey("observation")?observation(stored.get("observation",Document.class)):null; + permission(permitted);return new Receipt(prepared,state,observation,stored.containsKey("finishedAt")?stored.getDate("finishedAt").getTime():null); + } + /** Original target for observation only; this never returns or restores an execution token. */ + public ReviewOrphanTargetResolver.Target reconciliationTarget(Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var target=resolver.resolve(prepared.isolationId(),actor);permission(permitted);var stored=read(prepared.isolationId()); + if(!prepared.equals(authenticate(stored,actor,target)) || "PREPARED".equals(stored.get("state")))throw unavailable(); + permission(permitted);return target; + } + private Document ownedRecord(Claim claim,String actor) { + if(claim==null || !uuid(claim.token()))throw unavailable(); + var target=resolver.resolve(claim.prepared().isolationId(),actor);var stored=read(claim.prepared().isolationId()); + if(!target.equals(claim.target()) || !claim.prepared().equals(authenticate(stored,actor,target)) || !claim.token().equals(stored.get("token")))throw unavailable(); + return stored; + } + private static Document exact(Document stored){return new Document("_id",stored.get("_id")).append("$expr",exactExpression(stored));} + private static Document exactExpression(Document stored) { + return new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",literal(stored))), + new Document("$eq",List.of(new Document("$type","$intent.createdAt"),"long")),new Document("$eq",List.of(new Document("$type","$intent.expiresAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.target.versionIndex"),"int")),new Document("$eq",List.of(new Document("$type","$intent.target.binding.attempt"),"int")))); + } + static Document observationDocument(Observation observation) { + var status=observation.status();Document encoded=status==null?null:new Document("jobId",status.jobId()).append("state",status.state()).append("artifactRetained",status.artifactRetained()) + .append("createdAt",status.createdAt()).append("expiresAt",status.expiresAt()).append("workState",status.workState()); + return new Document("kind",observation.kind()).append("status",encoded).append("httpStatus",observation.httpStatus()); + } + static Observation observation(Document doc) { + if(doc==null || doc.size()!=3)throw unavailable();var status=doc.get("status",Document.class);RemoteReviewClient.Status parsed=null; + if(status!=null) { + if(status.size()!=6 || !(status.get("artifactRetained") instanceof Boolean) || !(status.get("createdAt") instanceof Long) || !(status.get("expiresAt") instanceof Long))throw unavailable(); + parsed=new RemoteReviewClient.Status(status.getString("jobId"),status.getString("state"),status.getBoolean("artifactRetained"),status.getLong("createdAt"),status.getLong("expiresAt"),status.getString("workState")); + } + var result=new Observation(doc.getString("kind"),parsed,doc.getInteger("httpStatus"));if(!Arrays.equals(bytes(doc),bytes(observationDocument(result))))throw unavailable();return result; + } + static void validateObservation(Observation observation,ReviewOrphanTargetResolver.Target target) { + validateObservation(observation,target.binding()); + } + static void validateObservation(Observation observation,net.modtale.model.project.RemoteReviewBinding binding) { + Objects.requireNonNull(observation);var status=observation.status();if(status==null)return; + if(!binding.jobId().equals(status.jobId()) || !Set.of("QUEUED","RUNNING","COMPLETED","CANCELLED","EXPIRED","HELD","UPLOADING","AWAITING_UPLOAD").contains(Objects.toString(status.state(),"")) + || status.createdAt()<=0 || status.expiresAt()<=status.createdAt() || status.workState()!=null && status.workState().length()>128 + || Set.of("QUEUED","RUNNING","COMPLETED","HELD").contains(status.state()) && !status.artifactRetained())throw unavailable(); + } + private Prepared authenticate(Document stored,String actor,ReviewOrphanTargetResolver.Target target) { + try { + if(stored==null || !target.isolationId().equals(stored.get("_id")))throw unavailable(); + var intent=stored.get("intent",Document.class); + if(intent==null || !(intent.get("createdAt") instanceof Long created) || !(intent.get("expiresAt") instanceof Long expires) + || created<1 || expires<=created || expires-created>120000)throw unavailable(); + var retained=archive.load(archiveId(target.isolationId()));validateArchive(retained,actor,target); + if(created!=retained.createdAt() || expires!=retained.expiresAt() + || !Arrays.equals(bytes(intent),bytes(intent(actor,target,created,expires))))throw unavailable(); + String state=stored.getString("state"); + if("PREPARED".equals(state)) {if(stored.size()!=3)throw unavailable();} + else { + if(!Set.of("EXECUTING","DISPATCHING","OBSERVED","UNKNOWN").contains(state) || !(stored.get("token") instanceof String token) || !uuid(token) + || !(stored.get("startedAt") instanceof Date started) || started.getTime()=expires)throw unavailable(); + if("EXECUTING".equals(state)) {if(stored.size()!=5)throw unavailable();} + else { + if(!(stored.get("dispatchAt") instanceof Date dispatched) || dispatched.before(started) || dispatched.getTime()>=expires)throw unavailable(); + if("DISPATCHING".equals(state)) {if(stored.size()!=6)throw unavailable();} + else { + if(stored.size()!=8 || !(stored.get("finishedAt") instanceof Date finished) || finished.before(dispatched))throw unavailable(); + var observation=observation(stored.get("observation",Document.class));validateObservation(observation,target); + if("UNKNOWN".equals(state)!= "UNKNOWN".equals(observation.kind()))throw unavailable(); + } + } + } + return new Prepared(target.isolationId(),digest(bytes(target(target))),created,expires); + }catch(RuntimeException invalid){throw unavailable();} + } + private static String archiveId(String isolationId) { + if(!uuid(isolationId))throw unavailable(); + byte[] hash=HexFormat.of().parseHex(digest(("warden-orphan-cancellation-intent-v1\0"+isolationId).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + hash[6]=(byte)((hash[6]&15)|128);hash[8]=(byte)((hash[8]&63)|128); + var buffer=java.nio.ByteBuffer.wrap(hash);return new UUID(buffer.getLong(),buffer.getLong()).toString(); + } + private static void validateArchive(ReviewSnapshotArchive.Snapshot retained,String actor,ReviewOrphanTargetResolver.Target target) { + if(retained==null || !archiveId(target.isolationId()).equals(retained.id()) || !actor.equals(retained.actorId()) + || retained.action()!=ReviewSnapshotArchive.Action.CANCEL_REMOTE_REVIEW || !target.projectId().equals(retained.projectId()) + || target.versionIndex()!=retained.versionIndex() || retained.expiresAt()-retained.createdAt()>120000 + || !Arrays.equals(retained.versionBytes(),bytes(target(target))))throw unavailable(); + } + private static boolean owned(Document armed,Document before,String token) { + if(armed==null || armed.size()!=5 || !(armed.get("startedAt") instanceof Date))return false; + var expected=new Document(before).append("state","EXECUTING").append("token",token).append("startedAt",armed.get("startedAt")); + return Arrays.equals(bytes(armed),bytes(expected)); + } + private Document read(String id){return ReviewRepairIo.collection(operations).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static Document intent(String actor,ReviewOrphanTargetResolver.Target target,long created,long expires) { + return new Document("action","CANCEL_REMOTE_REVIEW").append("actor",actor).append("target",target(target)).append("createdAt",created).append("expiresAt",expires); + } + private static Document target(ReviewOrphanTargetResolver.Target target) { + var b=target.binding();if(b==null || b.origin()==null || b.jobId()==null)throw unavailable(); + var binding=new Document("projectId",b.projectId()).append("versionId",b.versionId()).append("requestId",b.requestId()).append("attempt",b.attempt()) + .append("filePath",b.filePath()).append("artifactSha256",b.artifactSha256()).append("contextSha256",b.contextSha256()).append("policyVersion",b.policyVersion()) + .append("reviewConfigSha256",b.reviewConfigSha256()).append("jobId",b.jobId()).append("manualRescan",b.manualRescan()) + .append("origin",new Document("deploymentId",b.origin().deploymentId()).append("callerScope",b.origin().callerScope())); + return new Document("isolationId",target.isolationId()).append("projectId",target.projectId()).append("versionIndex",target.versionIndex()) + .append("beforeSha256",target.beforeSha256()).append("binding",binding); + } + private static byte[] bytes(Document document){var buffer=new RawBsonDocument(document,new DocumentCodec()).getByteBuffer().asNIO();byte[] result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static String digest(byte[] bytes){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw unavailable();}} + private static Document literal(Object value){return new Document("$literal",value);} + private static boolean uuid(String value){return value!=null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Cancellation is not permitted");} + private static IllegalStateException unavailable(){return new IllegalStateException("Cancellation intent is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java new file mode 100644 index 000000000..d6cc4730e --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java @@ -0,0 +1,39 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.*; +import org.bson.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; + +/** Original target evidence only; resolving a target never sends or authorizes a remote cancellation. */ +public final class ReviewOrphanTargetResolver { + public record Target(String isolationId,Object projectId,int versionIndex,String beforeSha256,RemoteReviewBinding binding) {} + private final ReviewSnapshotArchive archive; + private final ReviewIsolationExecutor isolation; + private final MongoTemplate mongo; + public ReviewOrphanTargetResolver(MongoTemplate mongo,ReviewSnapshotArchive archive,ReviewIsolationExecutor isolation) { + this.mongo=Objects.requireNonNull(mongo);this.archive=Objects.requireNonNull(archive);this.isolation=Objects.requireNonNull(isolation); + } + public Target resolve(String isolationId,String actor) { + var recovered=isolation.recover(isolationId,actor); + if(!"APPLIED".equals(recovered.receipt().outcome().state()))throw unavailable(); + var source=archive.load(isolationId); + try { + if(!source.id().equals(recovered.prepared().id()) || !source.actorId().equals(actor) || source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW + || source.createdAt()!=recovered.prepared().createdAt() || source.expiresAt()!=recovered.prepared().expiresAt() + || !source.projectId().equals(recovered.receipt().projectId()) || source.versionIndex()!=recovered.receipt().versionIndex() + || !digest(source.versionBytes()).equals(recovered.prepared().sha256()))throw unavailable(); + var original=new RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + var scan=original.get("scanResult",Document.class); + if(!"PENDING".equals(original.get("reviewStatus")) || !"SCANNING".equals(scan.get("status")) + || !"REMOTE_REVIEW".equals(scan.get("scanState")))throw unavailable(); + var binding=new ReviewRemoteTargetReader(mongo).validate(source.projectId(),original); + return new Target(isolationId,source.projectId(),source.versionIndex(),recovered.prepared().sha256(),binding); + } catch(RuntimeException invalid) {throw unavailable();} + } + private static String digest(byte[] value) { + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + private static IllegalStateException unavailable(){return new IllegalStateException("Original remote review target is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRemoteStatusObserver.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRemoteStatusObserver.java new file mode 100644 index 000000000..9fc4dbb6c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRemoteStatusObserver.java @@ -0,0 +1,119 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.*; +import java.util.function.Function; + +/** Shared one-shot remote status observations; never starts or cancels remote work. */ +final class ReviewRemoteStatusObserver implements AutoCloseable { + record Target(Document intent,net.modtale.model.project.RemoteReviewBinding binding) { + Target {intent=new RawBsonDocument(bytes(intent)).decode(new DocumentCodec());Objects.requireNonNull(binding);} + @Override public Document intent(){return new RawBsonDocument(bytes(intent)).decode(new DocumentCodec());} + } + record Receipt(String id,String state,ReviewOrphanCancellationJournal.Observation observation,Long receivedAt) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection observations; + private final RemoteReviewClient client; + private final Semaphore slots; + private final long budgetNanos; + private final AtomicBoolean closed=new AtomicBoolean(); + ReviewRemoteStatusObserver(MongoTemplate mongo,String collection,RemoteReviewClient client,int concurrency,Duration budget) { + if(concurrency<1 || concurrency>2 || budget==null || budget.compareTo(Duration.ofSeconds(1))<0 || budget.compareTo(Duration.ofSeconds(30))>0)throw new IllegalArgumentException("Invalid reconciliation capacity"); + this.client=Objects.requireNonNull(client);slots=new Semaphore(concurrency);budgetNanos=budget.toNanos(); + observations=mongo.getCollection(collection).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS); + } + Receipt check(String id,Function resolve,BooleanSupplier permitted) { + return check(id,resolve,permitted,Duration.ofNanos(budgetNanos)); + } + Receipt check(String id,Function resolve,BooleanSupplier permitted,Duration maximum) { + if(maximum==null || maximum.compareTo(Duration.ofSeconds(1))<0 || maximum.compareTo(Duration.ofSeconds(30))>0)throw new IllegalArgumentException("Invalid observation deadline"); + permission(permitted);if(!uuid(id))throw new IllegalArgumentException("Invalid observation identity"); + if(closed.get() || !slots.tryAcquire())throw new IllegalStateException("Reconciliation unavailable"); + long started=System.nanoTime();LongSupplier remaining=()->Math.min(budgetNanos,maximum.toNanos())-(System.nanoTime()-started); + BooleanSupplier allowed=()->{if(closed.get() || Thread.currentThread().isInterrupted() || remaining.getAsLong()<=0)throw new IllegalStateException("Reconciliation stopped");return permitted.getAsBoolean();}; + try(var io=ReviewRepairIo.open(remaining)) { + var target=resolve.apply(allowed);var expected=target.intent();var existing=read(id); + if(existing!=null)return decoded(existing,id,expected,target,allowed); + var reserved=new Document("_id",id).append("intent",expected).append("token",UUID.randomUUID().toString()).append("state","RESERVED"); + permission(allowed); + try {ReviewRepairIo.collection(observations).insertOne(reserved);} + catch(MongoException uncertain) { + var stored=read(id);if(stored==null)throw unavailable(); + if(!Arrays.equals(bytes(stored),bytes(reserved)))return decoded(stored,id,expected,target,allowed); + } + permission(allowed);ReviewOrphanCancellationJournal.Observation observation; + try { + var status=client.status(target.binding(),()->{ + if(!target.equals(resolve.apply(allowed)))throw unavailable(); + permission(allowed); + var reading=ReviewRepairIo.collection(observations).findOneAndUpdate(exact(reserved),List.of(new Document("$set",new Document("state","READING").append("startedAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + if(reading==null)return false;decoded(reading,id,expected,target,allowed);return allowed.getAsBoolean(); + },io::remainingNanos); + observation=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",status,null); + }catch(RemoteReviewClient.Unavailable failure) { + int code=failure.status();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==401||code==403||code==429?"UNAVAILABLE":"UNKNOWN"; + observation=new ReviewOrphanCancellationJournal.Observation(kind,null,code); + }catch(RemoteReviewClient.Superseded stopped){observation=new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,null);} + try(var cleanup=ReviewRepairIo.cleanup()) { + if(!target.equals(resolve.apply(permitted)))throw unavailable();ReviewOrphanCancellationJournal.validateObservation(observation,target.binding()); + var stored=read(id);decoded(stored,id,expected,target,permitted); + if("READING".equals(stored.get("state")) && reserved.get("token").equals(stored.get("token"))) { + permission(permitted); + var fields=new Document("state","UNKNOWN".equals(observation.kind())?"UNKNOWN":"OBSERVED") + .append("observation",literal(ReviewOrphanCancellationJournal.observationDocument(observation))).append("receivedAt","$$NOW"); + try {ReviewRepairIo.collection(observations).updateOne(exact(stored),List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY));} + catch(MongoException uncertain){ /* Read-back preserves uncertainty; no second GET. */ } + } + return receipt(id,resolve,permitted); + } + }finally{slots.release();} + } + /** Reads only the retained local observation, including after the original proposal expiry. */ + Receipt receipt(String id,Function resolve,BooleanSupplier permitted) { + permission(permitted);if(!uuid(id))throw new IllegalArgumentException("Invalid observation identity"); + var target=resolve.apply(permitted);return decoded(read(id),id,target.intent(),target,permitted); + } + private static Receipt decoded(Document stored,String id,Document intent, + Target target,BooleanSupplier permitted) { + try { + if(stored==null || !id.equals(stored.get("_id")) || !(stored.get("token") instanceof String token) || !uuid(token) + || !(stored.get("intent") instanceof Document expected) || !Arrays.equals(bytes(expected),bytes(intent)))throw unavailable(); + String state=stored.getString("state");ReviewOrphanCancellationJournal.Observation observation=null;Long received=null; + if("RESERVED".equals(state)) {if(stored.size()!=4)throw unavailable();} + else { + if(!(stored.get("startedAt") instanceof Date start))throw unavailable(); + if("READING".equals(state)) {if(stored.size()!=5)throw unavailable();} + else { + if(!Set.of("OBSERVED","UNKNOWN").contains(state) || stored.size()!=7 || !(stored.get("receivedAt") instanceof Date end) || end.before(start))throw unavailable(); + observation=ReviewOrphanCancellationJournal.observation(stored.get("observation",Document.class));ReviewOrphanCancellationJournal.validateObservation(observation,target.binding()); + if("UNKNOWN".equals(state)!= "UNKNOWN".equals(observation.kind()))throw unavailable();received=end.getTime(); + } + } + permission(permitted);return new Receipt(id,state,observation,received); + }catch(SecurityException denied){throw denied;} + catch(RuntimeException invalid){throw unavailable();} + } + private Document read(String id){return ReviewRepairIo.collection(observations).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static Document exact(Document stored){return new Document("_id",stored.get("_id")).append("$expr",new Document("$and",List.of( + new Document("$eq",List.of("$$ROOT",literal(stored))),new Document("$eq",List.of(new Document("$type","$intent.createdAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.expiresAt"),"long")))));} + private static byte[] bytes(Document doc){var buffer=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();byte[] result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static Document literal(Object value){return new Document("$literal",value);} + private static boolean uuid(String value){return value!=null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Reconciliation is not permitted");} + private static IllegalStateException unavailable(){return new IllegalStateException("Reconciliation observation is unavailable or inconsistent");} + @Override public void close(){closed.set(true);} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRemoteTargetReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRemoteTargetReader.java new file mode 100644 index 000000000..125e0d4d7 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRemoteTargetReader.java @@ -0,0 +1,64 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.RemoteReviewBinding; +import net.modtale.service.security.scan.ArtifactReviewContext; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.List; +import java.util.Objects; + +/** Captured evidence only: neither authorizes replacement nor observes remote job state. */ +public final class ReviewRemoteTargetReader { + public record Captured(RawReviewSnapshotReader.Captured snapshot, RemoteReviewBinding binding) {} + private final MongoTemplate mongo; + + public ReviewRemoteTargetReader(MongoTemplate mongo) { this.mongo=Objects.requireNonNull(mongo); } + + public Captured capture(Object projectId,int versionIndex,String versionId) { + var snapshot=new RawReviewSnapshotReader(mongo).capture(projectId,versionIndex,versionId); + var original=new RawBsonDocument(snapshot.versionBytes()).decode(new DocumentCodec()); + return new Captured(snapshot,validate(projectId,original)); + } + + public RemoteReviewBinding validate(Object projectId,Document original) { + try { + var scan=original.get("scanResult",Document.class); + var raw=scan==null ? original.get("retainedRemoteReview",Document.class) : scan.get("remoteReview",Document.class); + // Do not let mapping conversions manufacture a usable remote identity from malformed BSON. + for(String field:List.of("projectId","versionId","requestId","filePath","artifactSha256", + "contextSha256","policyVersion","reviewConfigSha256","jobId")) + if(!(raw.get(field) instanceof String))throw unavailable(); + if(!(raw.get("manualRescan") instanceof Boolean))throw unavailable(); + long attempt=attempt(raw.get("attempt")); + var origin=raw.get("origin",Document.class); + if(origin==null || !(origin.get("deploymentId") instanceof String) + || !(origin.get("callerScope") instanceof String))throw unavailable(); + var binding=mongo.getConverter().read(RemoteReviewBinding.class,raw); + if(!projectId.toString().equals(binding.projectId()) || !binding.versionId().equals(original.get("_id")) + || scan!=null && (!binding.requestId().equals(scan.get("scanRequestId")) + || !(scan.get("manualRescan") instanceof Boolean manual) || manual!=binding.manualRescan() + || attempt(scan.get("scanAttempt"))!=attempt) + || !binding.filePath().equals(original.get("fileUrl")) || !binding.artifactSha256().equals(original.get("hash")))throw unavailable(); + // Poll/result corruption must not prevent retaining an otherwise intact artifact identity. + var context=new Document(); + for(String field:List.of("_id","fileUrl","hash","gameVersions","dependencies","manifestId","manifestVersion","overrideFileUrl","modpackConfigs")) + if(original.containsKey(field))context.put(field,original.get(field)); + var version=mongo.getConverter().read(ProjectVersion.class,context); + if(!binding.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(version)))throw unavailable(); + return binding; + } catch(RuntimeException invalid) { throw unavailable(); } + } + + private static long attempt(Object value) { + if(!(value instanceof Integer || value instanceof Long))throw unavailable(); + long number=((Number)value).longValue(); + if(number<1 || number>Integer.MAX_VALUE)throw unavailable(); + return number; + } + private static IllegalStateException unavailable() { + return new IllegalStateException("Original remote review target is unavailable or inconsistent"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java new file mode 100644 index 000000000..c4d477d01 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java @@ -0,0 +1,91 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import net.modtale.service.user.account.AccountService; +import org.bson.types.ObjectId; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.function.BooleanSupplier; + +/** Account-bound boundary for the disabled-by-default moderator repair workflow. */ +public final class ReviewRepairAccess { + public record Position(String projectIdType,String projectId,int versionIndex) {} + public record Inspect(Position position,String versionId) {} + public record Preview(Position position,String versionId,String sha256,boolean eligible,String effect) {} + public record Prepare(String id,Position position,String versionId,String expectedSha256) {} + private final AccountService accounts; + private final ReviewRepairWorkflow workflow; + private final RawReviewSnapshotReader reader; + private final ReviewIsolationExecutor isolation; + public ReviewRepairAccess(AccountService accounts,ReviewRepairWorkflow workflow,RawReviewSnapshotReader reader,ReviewIsolationExecutor isolation) { + this.accounts=accounts;this.workflow=workflow;this.reader=reader;this.isolation=isolation; + } + public record Capability(String action) {} + public Capability capability() {var authority=authority();return workflow.read(()->new Capability("ISOLATE_LOCAL_REVIEW"),authority.allowed());} + public Preview inspect(Inspect request) { + var authority=authority();if(request==null)throw invalid();Object project=project(request.position()); + return workflow.read(()->{ + var captured=reader.capture(project,request.position().versionIndex(),request.versionId()); + return new Preview(request.position(),captured.versionId(),captured.sha256(),isolation.eligible(captured),"ISOLATE_LOCAL_REVIEW"); + },authority.allowed()); + } + public ReviewRepairPreparation.Prepared prepare(Prepare request) { + var authority=authority();if(request==null)throw invalid();Object project=project(request.position()); + var input=new ReviewRepairPreparation.Request(request.id(),project,request.position().versionIndex(),request.versionId(),request.expectedSha256(),authority.actor(),ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + return workflow.prepare(input,authority.allowed()); + } + public ReviewIsolationExecutor.Result execute(ReviewRepairPreparation.Prepared prepared) { + var authority=authority();validate(prepared);return workflow.isolate(prepared,authority.actor(),authority.allowed()); + } + public Receipt closeExpired(ReviewRepairPreparation.Prepared prepared) { + var authority=authority();validate(prepared);return receiptView(workflow.closeExpired(prepared,authority.actor(),authority.allowed())); + } + public record Receipt(Position position,String versionId,String beforeSha256,String state,String afterSha256) {} + public Receipt receipt(ReviewRepairPreparation.Prepared prepared) { + var authority=authority();validate(prepared);return workflow.read(()->{ + var receipt=isolation.receipt(prepared,authority.actor()); + return receiptView(receipt); + },authority.allowed()); + } + public ReviewRepairOperationReader.Page operations(String cursor,int limit) { + var authority=authority();return workflow.read(()->isolation.operations(authority.actor(),cursor,limit),authority.allowed()); + } + public record Recovered(ReviewRepairPreparation.Prepared prepared,Receipt receipt) {} + public Recovered recover(String id) { + var authority=authority(); + if(id==null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid(); + return workflow.read(()->{var recovered=isolation.recover(id,authority.actor());return new Recovered(recovered.prepared(),receiptView(recovered.receipt()));},authority.allowed()); + } + private static Receipt receiptView(ReviewIsolationExecutor.Receipt receipt) { + boolean objectId=receipt.projectId() instanceof ObjectId; + var position=new Position(objectId?"OBJECT_ID":"STRING",objectId?((ObjectId)receipt.projectId()).toHexString():(String)receipt.projectId(),receipt.versionIndex()); + return new Receipt(position,receipt.versionId(),receipt.beforeSha256(),receipt.outcome().state(),receipt.outcome().afterSha256()); + } + private record Authority(String actor,BooleanSupplier allowed) {} + private Authority authority() { + User user=current();if(!allowed(user))throw new SecurityException("Review repair is not permitted"); + String actor=user.getId(); + return new Authority(actor,()->{User refreshed=current();return allowed(refreshed)&&actor.equals(refreshed.getId());}); + } + private User current(){return accounts.getCurrentUser(SecurityContextHolder.getContext().getAuthentication());} + private static boolean allowed(User user) { + return user!=null && !user.isDeleted() && user.getId()!=null && !user.getId().isBlank() + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_REVIEW_READ) + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_VERSION_RESCAN); + } + private static Object project(Position position) { + if(position==null || position.projectId()==null || position.versionIndex()<0 || position.versionIndex()>16*1024*1024)throw invalid(); + if("OBJECT_ID".equals(position.projectIdType())) { + if(!position.projectId().matches("[0-9a-f]{24}"))throw invalid();return new ObjectId(position.projectId()); + } + if(!"STRING".equals(position.projectIdType()) || position.projectId().isEmpty() || position.projectId().length()>128 + || !java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(position.projectId()))throw invalid(); + return position.projectId(); + } + private static void validate(ReviewRepairPreparation.Prepared prepared) { + if(prepared==null || prepared.id()==null || !prepared.id().matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || prepared.sha256()==null || !prepared.sha256().matches("[0-9a-f]{64}") || prepared.createdAt()<=0 || prepared.expiresAt()<=prepared.createdAt() + || prepared.expiresAt()-prepared.createdAt()>900000)throw invalid(); + } + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review repair request");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java new file mode 100644 index 000000000..66a53714b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java @@ -0,0 +1,32 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; + +@Configuration(proxyBeanMethods=false) +@Import(ProjectMutationConfiguration.class) +@ConditionalOnProperty(name="app.warden.repair.enabled",havingValue="true") +@EnableConfigurationProperties(AppReviewRepairProperties.class) +public class ReviewRepairConfiguration { + @Bean ReviewSnapshotArchive reviewSnapshotArchive(MongoTemplate mongo,AppReviewRepairProperties properties) { + return new ReviewSnapshotArchive(mongo,properties.activeKey(),properties.decodedKeys()); + } + @Bean ReviewRepairJournal reviewRepairJournal(MongoTemplate mongo,ReviewSnapshotArchive archive){return new ReviewRepairJournal(mongo,archive);} + @Bean(initMethod="initializeDiscovery") ReviewIsolationExecutor reviewIsolationExecutor(MongoTemplate mongo,ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,ReviewRepairJournal journal) { + return new ReviewIsolationExecutor(mongo,archive,reader,journal); + } + @Bean ReviewRepairWorkflow reviewRepairWorkflow(ReviewRepairPreparation preparation,ReviewIsolationExecutor isolation,AppReviewRepairProperties properties) { + return new ReviewRepairWorkflow(preparation,isolation,properties.concurrency()); + } + @Bean ReviewRepairAccess reviewRepairAccess(net.modtale.service.user.account.AccountService accounts,ReviewRepairWorkflow workflow,RawReviewSnapshotReader reader,ReviewIsolationExecutor isolation) { + return new ReviewRepairAccess(accounts,workflow,reader,isolation); + } + @Bean RawReviewSnapshotReader rawReviewSnapshotReader(MongoTemplate mongo){return new RawReviewSnapshotReader(mongo);} + @Bean ReviewRepairPreparation reviewRepairPreparation(ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,AppReviewRepairProperties properties) { + return new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),properties.concurrency(),properties.preparationLifetimeMillis()); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java new file mode 100644 index 000000000..6fe191a29 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java @@ -0,0 +1,86 @@ +package net.modtale.service.admin.review; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.TransactionOptions; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** Synchronous workflow scope; never inherited by another thread or reused after return. */ +final class ReviewRepairIo implements AutoCloseable { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final ReviewRepairIo previous; + private final LongSupplier remainingNanos; + private boolean closed; + + private ReviewRepairIo(LongSupplier remainingNanos, boolean independent) { + java.util.Objects.requireNonNull(remainingNanos); + previous = CURRENT.get(); + this.remainingNanos = independent || previous == null ? remainingNanos + : () -> Math.min(previous.remainingNanos.getAsLong(), remainingNanos.getAsLong()); + CURRENT.set(this); + } + + static ReviewRepairIo open(LongSupplier remainingNanos) { return new ReviewRepairIo(remainingNanos, false); } + + // Only conservative receipt reconciliation/bookkeeping may enter this independent cleanup budget. + static ReviewRepairIo cleanup() { + long started = System.nanoTime(); + return new ReviewRepairIo(() -> TimeUnit.SECONDS.toNanos(5) - (System.nanoTime() - started), true); + } + + long remainingNanos() { + if (closed) throw new IllegalStateException("Review repair I/O scope is closed"); + return remainingNanos.getAsLong(); + } + + static long currentRemainingNanos() { + var scope=CURRENT.get(); + if(scope==null)throw new IllegalStateException("Review repair I/O scope is required"); + return scope.remainingNanos(); + } + + private static long timeoutMillis() { + long nanos = CURRENT.get().remainingNanos.getAsLong(); + if (nanos <= 0) throw new IllegalStateException("Review repair I/O deadline exceeded"); + // Never round a positive remainder down to zero: the driver interprets zero as unlimited. + return Math.min(5000, Math.max(1, TimeUnit.NANOSECONDS.toMillis(nanos))); + } + + static MongoCollection collection(MongoCollection collection) { + return CURRENT.get() == null ? collection : collection.withTimeout(timeoutMillis(), TimeUnit.MILLISECONDS); + } + + static MongoCollection collection(MongoCollection collection, com.mongodb.client.ClientSession session) { + if (session != null && session.hasActiveTransaction()) { + // The driver forbids changing timeoutMS inside a transaction. Its transaction budget applies. + if (CURRENT.get() != null) timeoutMillis(); + return collection; + } + return collection(collection); + } + + static MongoDatabase database(MongoDatabase database) { + return CURRENT.get() == null ? database : database.withTimeout(timeoutMillis(), TimeUnit.MILLISECONDS); + } + + static ClientSessionOptions sessionOptions() { + var builder = ClientSessionOptions.builder().causallyConsistent(false); + if (CURRENT.get() != null) builder.defaultTimeout(timeoutMillis(), TimeUnit.MILLISECONDS); + return builder.build(); + } + + static TransactionOptions transactionOptions(TransactionOptions base) { + if (CURRENT.get() == null) return base; + return TransactionOptions.builder().readConcern(base.getReadConcern()).readPreference(base.getReadPreference()) + .writeConcern(base.getWriteConcern()).timeout(timeoutMillis(), TimeUnit.MILLISECONDS).build(); + } + + @Override public void close() { + if (closed) return; + if (CURRENT.get() != this) throw new IllegalStateException("Invalid repair I/O scope"); + closed = true; + if (previous == null) CURRENT.remove(); else CURRENT.set(previous); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java new file mode 100644 index 000000000..a3e3797c8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java @@ -0,0 +1,109 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** One-shot execution claims. An uncertain claim is never automatically reclaimed. */ +public final class ReviewRepairJournal { + public static final String COLLECTION="review_repair_operations"; + public record Claim(String id,String token) { + public Claim {if(!uuid(id)||!uuid(token))throw new IllegalArgumentException("Invalid repair claim");} + } + private final MongoCollection operations; + private final ReviewSnapshotArchive archive; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + public ReviewRepairJournal(MongoTemplate mongo,ReviewSnapshotArchive archive) { + this.archive=Objects.requireNonNull(archive);operations=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,TimeUnit.SECONDS)); + } + public Claim claim(ReviewRepairPreparation.Prepared prepared,String actor,ReviewSnapshotArchive.Action action,BooleanSupplier permitted) { + requirePermission(permitted);Objects.requireNonNull(prepared); + var snapshot=archive.load(prepared.id()); + if(!snapshot.actorId().equals(actor)||snapshot.action()!=action||snapshot.createdAt()!=prepared.createdAt()||snapshot.expiresAt()!=prepared.expiresAt() + ||!digest(snapshot.versionBytes()).equals(prepared.sha256()))throw unavailable(); + var record=new Document("_id",prepared.id()).append("token",UUID.randomUUID().toString()).append("sha256",prepared.sha256()) + .append("actor",actor).append("action",action.name()).append("createdAt",prepared.createdAt()).append("expiresAt",prepared.expiresAt()).append("state","RESERVED"); + requirePermission(permitted); + try {ReviewRepairIo.collection(operations).insertOne(record);} + catch(MongoException uncertain) { + var existing=read(prepared.id()); + if(existing==null)throw unavailable(); + if(!record.equals(existing))return null; + } + requirePermission(permitted); + var query=new Document(record).append("$expr",new Document("$and",List.of(new Document("$gte",List.of("$$NOW",new Date(prepared.createdAt()))), + new Document("$lt",List.of("$$NOW",new Date(prepared.expiresAt())))))); + try { + var armed=ReviewRepairIo.collection(operations).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","EXECUTING").append("startedAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + requirePermission(permitted); + return armed!=null && executing(armed,record)?new Claim(prepared.id(),record.getString("token")):null; + } catch(MongoException uncertain) { + requirePermission(permitted);var armed=read(prepared.id());requirePermission(permitted); + if(armed!=null && executing(armed,record))return new Claim(prepared.id(),record.getString("token")); + throw unavailable(); + } + } + public boolean markUnknown(Claim claim,BooleanSupplier permitted) { + requirePermission(permitted);var stored=read(claim.id());if(stored==null || !claim.token().equals(stored.get("token")))return false; + if("UNKNOWN".equals(stored.get("state")))return true; + if(!"EXECUTING".equals(stored.get("state")) || stored.size()!=9 || !(stored.get("startedAt") instanceof Date))return false; + requirePermission(permitted); + try { + return ReviewRepairIo.collection(operations).updateOne(stored,List.of(new Document("$set",new Document("state","UNKNOWN").append("uncertainAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } catch(MongoException uncertain) { + var current=read(claim.id());return current!=null && claim.token().equals(current.get("token")) && "UNKNOWN".equals(current.get("state")); + } + } + /** Closes an expired claim; never grants a new execution token or changes a version. */ + public boolean closeExpired(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + requirePermission(permitted); + var source=archive.load(prepared.id()); + if(!source.actorId().equals(actor) || source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW + || source.createdAt()!=prepared.createdAt() || source.expiresAt()!=prepared.expiresAt() + || !digest(source.versionBytes()).equals(prepared.sha256()))throw unavailable(); + var stored=read(prepared.id()); + if(!closable(stored,prepared,actor))return false; + var conditions=List.of(new Document("$gte",List.of("$$NOW",new Date(prepared.expiresAt()))), + new Document("$eq",List.of(new Document("$size",new Document("$objectToArray","$$ROOT")),stored.size())), + new Document("$eq",List.of(new Document("$type","$createdAt"),"long")), + new Document("$eq",List.of(new Document("$type","$expiresAt"),"long"))); + var query=new Document(stored).append("$expr",new Document("$and",conditions)); + var resolution=new Document("kind","EXPIRED_CLAIM_CLOSED").append("actorId",new Document("$literal",actor)) + .append("previousState",stored.getString("state")).append("closedAt","$$NOW"); + requirePermission(permitted); + // A competing isolation must update this same journal document in its version transaction. + // Either its terminal receipt wins, or this write prevents that transaction from committing. + return ReviewRepairIo.collection(operations).updateOne(query,List.of(new Document("$set",new Document("state","NOT_APPLIED") + .append("afterSha256",null).append("finishedAt","$$NOW").append("resolution",resolution))),new UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + private static boolean closable(Document stored,ReviewRepairPreparation.Prepared prepared,String actor) { + if(stored==null || !(stored.get("token") instanceof String token) || !uuid(token) + || !actor.equals(stored.get("actor")) || !"ISOLATE_REVIEW".equals(stored.get("action")) + || !prepared.sha256().equals(stored.get("sha256")) || !Long.valueOf(prepared.createdAt()).equals(stored.get("createdAt")) + || !Long.valueOf(prepared.expiresAt()).equals(stored.get("expiresAt")))return false; + return switch(Objects.toString(stored.get("state"),"")) { + case "RESERVED" -> stored.size()==8; + case "EXECUTING" -> stored.size()==9 && stored.get("startedAt") instanceof Date; + case "UNKNOWN" -> stored.size()==10 && stored.get("startedAt") instanceof Date && stored.get("uncertainAt") instanceof Date; + default -> false; + }; + } + private static boolean executing(Document stored,Document reserved) { + var expected=new Document(reserved);expected.put("state","EXECUTING"); + if(stored.size()!=9 || !(stored.get("startedAt") instanceof Date))return false; + var copy=new Document(stored);copy.remove("startedAt");return expected.equals(copy); + } + private Document read(String id){return ReviewRepairIo.collection(operations).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static String digest(byte[] bytes){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw unavailable();}} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void requirePermission(BooleanSupplier permitted){if(permitted==null||!permitted.getAsBoolean())throw new SecurityException("Repair execution is not permitted");} + private static IllegalStateException unavailable(){return new IllegalStateException("Repair execution claim is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java new file mode 100644 index 000000000..e0c94fd91 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java @@ -0,0 +1,47 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Actor-scoped discovery only; a recorded state never substitutes for an authenticated receipt. */ +public final class ReviewRepairOperationReader { + public record Item(String id,String recordedState) {} + public record Page(List items,String nextCursor,String order) {} + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + public static final String INDEX="review_repair_actor_action_id"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final List STATES=List.of("RESERVED","EXECUTING","UNKNOWN","APPLIED","NOT_APPLIED"); + private final MongoCollection operations; + public ReviewRepairOperationReader(MongoTemplate mongo) { + operations=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public void initialize() { + operations.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).withTimeout(5000,TimeUnit.MILLISECONDS) + .createIndex(new Document("actor",1).append("action",1).append("_id",1),new IndexOptions().name(INDEX).collation(BINARY)); + } + public Page page(String actor,String cursor,int limit) { + if(actor==null || actor.isBlank() || actor.length()>256 || limit<1 || limit>25)throw new IllegalArgumentException("Invalid operation page"); + String after=null; + if(cursor!=null) {if(!cursor.matches("r1\\."+UUID))throw new IllegalArgumentException("Invalid operation cursor");after=cursor.substring(3);} + var id=new Document("$type","string").append("$regex","^"+UUID+"$");if(after!=null)id.append("$gt",after); + var scalar=new Document("$and",List.of(new Document("$eq",List.of(new Document("$type","$actor"),"string")),new Document("$eq",List.of(new Document("$type","$action"),"string")))); + var query=new Document("actor",actor).append("action","ISOLATE_REVIEW").append("_id",id).append("$expr",scalar); + var state=new Document("$cond",List.of(new Document("$in",List.of("$state",STATES)),"$state","UNKNOWN")); + var pipeline=List.of(new Document("$match",query),new Document("$sort",new Document("_id",1)),new Document("$limit",limit+1), + new Document("$project",new Document("_id",1).append("recordedState",state))); + var records=ReviewRepairIo.collection(operations).aggregate(pipeline).hintString(INDEX).collation(BINARY).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).into(new ArrayList<>()); + boolean more=records.size()>limit;var items=new ArrayList(); + for(var record:records.subList(0,Math.min(records.size(),limit))) { + String operationId=record.getString("_id"),recordedState=record.getString("recordedState"); + if(operationId==null || !operationId.matches(UUID) || !STATES.contains(recordedState))throw new IllegalStateException("Invalid operation record"); + items.add(new Item(operationId,recordedState)); + } + return new Page(List.copyOf(items),more?"r1."+items.getLast().id():null,"OPERATION_ID"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java new file mode 100644 index 000000000..316040a67 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java @@ -0,0 +1,55 @@ +package net.modtale.service.admin.review; + +import org.bson.types.ObjectId; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.function.BooleanSupplier; + +/** Prepares immutable evidence for a proposed action; never mutates the project or starts work. */ +public final class ReviewRepairPreparation { + public record Request(String id,Object projectId,int versionIndex,String versionId,String expectedSha256,String actorId,ReviewSnapshotArchive.Action action) { + public Request { + if(id==null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || !(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128) + || versionIndex<0 || versionIndex>16*1024*1024 || versionId==null || versionId.isBlank() || versionId.length()>128 + || versionId.chars().anyMatch(Character::isISOControl) || expectedSha256==null || !expectedSha256.matches("[0-9a-f]{64}") + || actorId==null || actorId.isBlank() || actorId.length()>256 || action==null)throw new IllegalArgumentException("Invalid repair preparation"); + } + @Override public String toString(){return "ReviewRepairPreparation["+id+"]";} + } + public record Prepared(String id,String sha256,long createdAt,long expiresAt) {} + private final ReviewSnapshotArchive archive; + private final RawReviewSnapshotReader reader; + private final Clock clock; + private final long lifetimeMillis; + private final Semaphore slots; + public ReviewRepairPreparation(ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,Clock clock,int concurrency,long lifetimeMillis) { + if(concurrency<1 || concurrency>2 || lifetimeMillis<1000 || lifetimeMillis>900000)throw new IllegalArgumentException("Invalid repair preparation limits"); + this.archive=Objects.requireNonNull(archive);this.reader=Objects.requireNonNull(reader);this.clock=Objects.requireNonNull(clock);this.lifetimeMillis=lifetimeMillis;slots=new Semaphore(concurrency); + } + public Prepared prepare(Request request,BooleanSupplier permitted) { + Objects.requireNonNull(request);Objects.requireNonNull(permitted);requirePermission(permitted); + if(!slots.tryAcquire())throw new IllegalStateException("Repair preparation is busy"); + try { + var stored=archive.find(request.id());requirePermission(permitted); + if(stored!=null && (!stored.projectId().equals(request.projectId()) || stored.versionIndex()!=request.versionIndex() + || !stored.actorId().equals(request.actorId()) || stored.action()!=request.action()))throw conflict(); + if(stored!=null)requireLive(stored); + var captured=reader.capture(request.projectId(),request.versionIndex(),request.versionId());requirePermission(permitted); + if(!captured.sha256().equals(request.expectedSha256()) || stored!=null && !Arrays.equals(stored.versionBytes(),captured.versionBytes()))throw conflict(); + if(stored==null) { + long now=clock.millis();stored=archive.retain(captured.forArchive(request.id(),request.actorId(),request.action(),now,Math.addExact(now,lifetimeMillis))); + } + requirePermission(permitted);requireLive(stored); + if(!reader.isCurrent(captured))throw conflict(); + requirePermission(permitted);requireLive(stored); + return new Prepared(stored.id(),captured.sha256(),stored.createdAt(),stored.expiresAt()); + } finally {slots.release();} + } + private void requireLive(ReviewSnapshotArchive.Snapshot stored) { + long now=clock.millis();if(now=stored.expiresAt())throw conflict(); + } + private static void requirePermission(BooleanSupplier permitted){if(!permitted.getAsBoolean())throw new SecurityException("Repair preparation is not permitted");} + private static IllegalStateException conflict(){return new IllegalStateException("Repair preparation changed or expired");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java new file mode 100644 index 000000000..6ad60ab4c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java @@ -0,0 +1,121 @@ +package net.modtale.service.admin.review; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; + +/** Shared, fail-fast admission for preparation and isolation; owns no background workers. */ +public final class ReviewRepairWorkflow implements AutoCloseable { + public record Status(String state, int active, int overdue) { + public Status(String state, int active) { this(state, active, 0); } + } + private final ReviewRepairPreparation preparation; + private final ReviewIsolationExecutor isolation; + private final int concurrency; + private final long durationNanos; + private final LongSupplier ticker; + private boolean accepting = true; + private int active; + private final java.util.Set admissions = new java.util.HashSet<>(); + + public ReviewRepairWorkflow(ReviewRepairPreparation preparation, ReviewIsolationExecutor isolation, int concurrency) { + this(preparation, isolation, concurrency, 30000, System::nanoTime); + } + + ReviewRepairWorkflow(ReviewRepairPreparation preparation, ReviewIsolationExecutor isolation, int concurrency, + long durationMillis, LongSupplier ticker) { + if (concurrency < 1 || concurrency > 2 || durationMillis < 1000 || durationMillis > 120000) + throw new IllegalArgumentException("Invalid repair workflow limits"); + this.preparation = Objects.requireNonNull(preparation); + this.isolation = Objects.requireNonNull(isolation); + this.concurrency = concurrency; + this.durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis); + this.ticker = Objects.requireNonNull(ticker); + } + + public ReviewRepairPreparation.Prepared prepare(ReviewRepairPreparation.Request request, BooleanSupplier permitted) { + Objects.requireNonNull(request); + try (var admission = admit(permitted); var io = ReviewRepairIo.open(admission::remainingNanos)) { + return preparation.prepare(request, admission::permitted); + } + } + + public ReviewIsolationExecutor.Result isolate(ReviewRepairPreparation.Prepared prepared, String actor, BooleanSupplier permitted) { + Objects.requireNonNull(prepared); + Objects.requireNonNull(actor); + try (var admission = admit(permitted); var io = ReviewRepairIo.open(admission::remainingNanos)) { + return isolation.execute(prepared, actor, admission::permitted); + } + } + + public ReviewIsolationExecutor.Receipt closeExpired(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + Objects.requireNonNull(prepared);Objects.requireNonNull(actor); + try(var admission=admit(permitted);var io=ReviewRepairIo.open(admission::remainingNanos)) { + return isolation.closeExpired(prepared,actor,admission::permitted); + } + } + + T read(java.util.function.Supplier operation, BooleanSupplier permitted) { + return call(allowed -> operation.get(), permitted); + } + + T call(java.util.function.Function operation, BooleanSupplier permitted) { + try (var admission = admit(permitted); var io = ReviewRepairIo.open(admission::remainingNanos)) { + if (!admission.permitted()) throw new SecurityException("Review repair is not permitted"); + T result = operation.apply(admission::permitted); + if (!admission.permitted()) throw new SecurityException("Review repair is not permitted"); + return result; + } + } + + private Admission admit(BooleanSupplier permitted) { + Objects.requireNonNull(permitted); + if (!permitted.getAsBoolean()) throw new SecurityException("Review repair is not permitted"); + synchronized (this) { + if (!accepting || Thread.currentThread().isInterrupted()) throw new IllegalStateException("Review repair is stopped"); + if (active >= concurrency) throw new IllegalStateException("Review repair is busy"); + var admission = new Admission(permitted, ticker.getAsLong()); + active++; + admissions.add(admission); + return admission; + } + } + + public synchronized Status status() { + long now = ticker.getAsLong(); + int overdue = (int) admissions.stream().filter(admission -> now - admission.started >= durationNanos).count(); + return new Status(accepting ? "OPEN" : active == 0 ? "CLOSED" : "DRAINING", active, overdue); + } + + // In-flight calls retain their slots until they actually return. A commit already entered may complete. + @Override public synchronized void close() { accepting = false; } + + private final class Admission implements AutoCloseable { + private final BooleanSupplier permission; + private final long started; + private boolean released; + Admission(BooleanSupplier permission, long started) { this.permission = permission; this.started = started; } + boolean permitted() { + checkpoint(); + boolean allowed = permission.getAsBoolean(); + checkpoint(); + return allowed; + } + long remainingNanos() { + checkpoint(); + return durationNanos - (ticker.getAsLong() - started); + } + private void checkpoint() { + synchronized (ReviewRepairWorkflow.this) { + if (released || !accepting || Thread.currentThread().isInterrupted()) throw new IllegalStateException("Review repair is stopped"); + if (ticker.getAsLong() - started >= durationNanos) throw new IllegalStateException("Review repair deadline exceeded"); + } + } + @Override public void close() { + synchronized (ReviewRepairWorkflow.this) { + if (!released) { released = true; active--; admissions.remove(this); } + } + } + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementActivationDecision.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementActivationDecision.java new file mode 100644 index 000000000..c9d6f3f5b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementActivationDecision.java @@ -0,0 +1,138 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Signed admission decision only. It does not dispatch work or resolve any security finding. */ +public final class ReviewReplacementActivationDecision { + public record Prepared(String id,String replacementId,String observationId,String observationSha256,String heldSha256, + String rule,boolean acknowledgedUncertainty,long createdAt,long expiresAt) {} + record Context(ReviewSnapshotArchive.Snapshot decision,Document admission,net.modtale.model.project.RemoteReviewBinding replacement) {} + private record Recovered(Prepared prepared,ReviewSnapshotArchive.Snapshot snapshot) {} + private final ReviewSnapshotArchive archive; + private final ReviewReplacementPreparation preparation; + private final ReviewReplacementExecutor executor; + private final ReviewReplacementJobAccounting accounting; + private final RawReviewSnapshotReader reader; + private final MongoCollection admissions; + private final Clock clock; + private final long lifetimeMillis; + public ReviewReplacementActivationDecision(MongoTemplate mongo,ReviewSnapshotArchive archive,ReviewReplacementPreparation preparation, + ReviewReplacementExecutor executor,ReviewReplacementJobAccounting accounting,RawReviewSnapshotReader reader,Clock clock,long lifetimeMillis) { + if(lifetimeMillis<1000 || lifetimeMillis>120000)throw new IllegalArgumentException("Invalid activation lifetime"); + this.archive=Objects.requireNonNull(archive);this.preparation=Objects.requireNonNull(preparation);this.executor=Objects.requireNonNull(executor); + this.accounting=Objects.requireNonNull(accounting);this.reader=Objects.requireNonNull(reader);this.clock=Objects.requireNonNull(clock);this.lifetimeMillis=lifetimeMillis; + admissions=mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + + public Prepared prepare(String id,ReviewReplacementPreparation.Prepared replacement,String observationId,String actor, + boolean acknowledgeUncertainty,BooleanSupplier permitted) { + permission(permitted); + if(!uuid(id) || !uuid(observationId) || actor==null || actor.isBlank() || actor.length()>256)throw new IllegalArgumentException("Invalid activation identity"); + var original=archive.load(replacement.id());permission(permitted); + // The acting moderator may differ from the historical author of the staged proposal. + var recovered=preparation.recoverEvidence(replacement.id(),original.actorId(),permitted); + if(!replacement.equals(recovered.prepared()))throw conflict(); + var staged=executor.receipt(replacement,original.actorId(),permitted); + if(!"APPLIED".equals(staged.state()))throw conflict(); + var source=recovered.before(); + var captured=reader.capture(source.projectId(),source.versionIndex(),replacement.replacement().versionId());permission(permitted); + if(!captured.sha256().equals(staged.afterSha256()))throw conflict(); + var admission=readAdmission(replacement.id());permission(permitted); + var expected=new Document("_id",replacement.id()).append("beforeArchiveId",replacement.beforeArchiveId()).append("beforeSha256",replacement.beforeSha256()) + .append("afterSha256",staged.afterSha256()).append("actor",original.actorId()).append("projectId",source.projectId()) + .append("versionIndex",source.versionIndex()).append("requestId",replacement.replacement().requestId()).append("state","HELD"); + if(admission==null || !Arrays.equals(bytes(expected),bytes(admission)))throw conflict(); + var observation=accounting.receipt(observationId,replacement,original.actorId(),permitted); + if(!Set.of("OBSERVED","UNKNOWN").contains(observation.state()))throw new IllegalStateException("A completed status observation is required before activation"); + var observationBody=observation(observation); + boolean completed=completed(observation); + if(!completed && !acknowledgeUncertainty)throw new IllegalStateException("The original job remains uncertain; acknowledge possible duplicate work before activation"); + String rule=completed?"ORIGINAL_COMPLETED":"ACKNOWLEDGED_UNCERTAINTY"; + var payload=new Document("schema",1).append("replacementId",replacement.id()).append("admission",admission) + .append("observation",observationBody).append("heldSha256",captured.sha256()).append("rule",rule).append("acknowledgedUncertainty",acknowledgeUncertainty); + var existing=archive.find(id);permission(permitted); + long created=existing==null?clock.millis():existing.createdAt(),expires=existing==null?Math.addExact(created,lifetimeMillis):existing.expiresAt(); + live(created,expires); + var candidate=new ReviewSnapshotArchive.Snapshot(id,source.projectId(),source.versionIndex(),actor,ReviewSnapshotArchive.Action.REPLACEMENT_ACTIVATION,created,expires,bytes(payload)); + permission(permitted);archive.retain(candidate);permission(permitted); + if(!reader.isCurrent(captured) || !Arrays.equals(bytes(admission),bytes(readAdmission(replacement.id()))) + || !observation.equals(accounting.receipt(observationId,replacement,original.actorId(),permitted)))throw conflict(); + var result=recover(id,actor,permitted);live(result.createdAt(),result.expiresAt());return result; + } + + /** Historical read-back only; expiry never extends and recovery does not activate the admission. */ + public Prepared recover(String id,String actor,BooleanSupplier permitted) { + return recoverEvidence(id,actor,permitted).prepared(); + } + private Recovered recoverEvidence(String id,String actor,BooleanSupplier permitted) { + permission(permitted);var stored=archive.load(id);permission(permitted); + if(stored.action()!=ReviewSnapshotArchive.Action.REPLACEMENT_ACTIVATION || !stored.actorId().equals(actor) + || stored.expiresAt()-stored.createdAt()>120000)throw conflict(); + var payload=new RawBsonDocument(stored.versionBytes()).decode(new DocumentCodec()); + if(payload.size()!=7 || !Integer.valueOf(1).equals(payload.get("schema")) || !(payload.get("acknowledgedUncertainty") instanceof Boolean ack) + || !(payload.get("heldSha256") instanceof String held) || !held.matches("[0-9a-f]{64}"))throw conflict(); + String replacement=payload.getString("replacementId"),rule=payload.getString("rule");var observation=payload.get("observation",Document.class); + if(!uuid(replacement) || observation==null || !uuid(observation.getString("id")) || !replacement.equals(observation.get("replacementId")) + || !Set.of("ORIGINAL_COMPLETED","ACKNOWLEDGED_UNCERTAINTY").contains(Objects.toString(rule,"")) + || "ACKNOWLEDGED_UNCERTAINTY".equals(rule) && !ack)throw conflict(); + if(!rule.equals(completed(observation(observation))?"ORIGINAL_COMPLETED":"ACKNOWLEDGED_UNCERTAINTY"))throw conflict(); + var admission=payload.get("admission",Document.class); + if(admission==null || !replacement.equals(admission.get("_id")) || !stored.projectId().equals(admission.get("projectId")) + || !Integer.valueOf(stored.versionIndex()).equals(admission.get("versionIndex")) || !held.equals(admission.get("afterSha256")) + || !"HELD".equals(admission.get("state")))throw conflict(); + permission(permitted);return new Recovered(new Prepared(id,replacement,observation.getString("id"),digest(bytes(observation)),held,rule,ack,stored.createdAt(),stored.expiresAt()),stored); + } + private Document readAdmission(String id) { + return ReviewRepairIo.collection(admissions).find(new Document("_id",id)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + } + Context verifyCurrent(Prepared expected,String actor,BooleanSupplier permitted) { + if(!expected.equals(recover(expected.id(),actor,permitted)))throw conflict(); + var original=archive.load(expected.replacementId()); + var replacement=preparation.recover(original.id(),original.actorId(),permitted); + if(!expected.equals(prepare(expected.id(),replacement,expected.observationId(),actor,expected.acknowledgedUncertainty(),permitted)))throw conflict(); + var verified=recoverEvidence(expected.id(),actor,permitted); + if(!expected.equals(verified.prepared()))throw conflict(); + var source=verified.snapshot(); + var payload=new RawBsonDocument(source.versionBytes()).decode(new DocumentCodec()); + return new Context(source,payload.get("admission",Document.class),replacement.replacement()); + } + private static boolean completed(ReviewReplacementJobAccounting.Receipt receipt) { + var observation=receipt.observation();return "OBSERVED".equals(receipt.state()) && observation!=null && "REMOTE_STATUS".equals(observation.kind()) + && "COMPLETED".equals(observation.status().state()) && "COMPLETED".equals(observation.status().workState()); + } + private static Document observation(ReviewReplacementJobAccounting.Receipt receipt) { + return new Document("id",receipt.id()).append("replacementId",receipt.replacementId()).append("state",receipt.state()) + .append("observation",receipt.observation()==null?null:ReviewOrphanCancellationJournal.observationDocument(receipt.observation())).append("receivedAt",receipt.receivedAt()); + } + private static ReviewReplacementJobAccounting.Receipt observation(Document body) { + if(body.size()!=5 || !Set.of("RESERVED","READING","OBSERVED","UNKNOWN").contains(Objects.toString(body.get("state"),"")) + || body.get("receivedAt")!=null && !(body.get("receivedAt") instanceof Long))throw conflict(); + var decoded=body.get("observation")==null?null:ReviewOrphanCancellationJournal.observation(body.get("observation",Document.class)); + boolean finished=Set.of("OBSERVED","UNKNOWN").contains(body.getString("state")); + if(finished!=(decoded!=null) || finished!=(body.get("receivedAt")!=null) + || finished && (body.getLong("receivedAt")<=0 || "UNKNOWN".equals(body.getString("state"))!="UNKNOWN".equals(decoded.kind())))throw conflict(); + var result=new ReviewReplacementJobAccounting.Receipt(body.getString("id"),body.getString("replacementId"),body.getString("state"),decoded,body.getLong("receivedAt")); + if(!Arrays.equals(bytes(body),bytes(observation(result))))throw conflict();return result; + } + private static byte[] bytes(Document doc) { + if(doc==null)throw conflict();var buffer=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes; + } + private static String digest(byte[] bytes) { + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + private void live(long created,long expires){long now=clock.millis();if(now=expires)throw conflict();} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Activation preparation is not permitted");} + private static IllegalStateException conflict(){return new IllegalStateException("Activation decision changed or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementActivator.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementActivator.java new file mode 100644 index 000000000..60ca6888d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementActivator.java @@ -0,0 +1,108 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.*; + +/** Consumes a signed decision atomically; the existing remote scheduler delivers the fixed request. */ +public final class ReviewReplacementActivator { + public record Result(String state,String afterSha256) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final List STATUSES=List.of("PENDING","PUBLISHED","UNLISTED","PRIVATE"); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT) + .readPreference(ReadPreference.primary()).writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + private final MongoTemplate mongo; + private final ReviewReplacementActivationDecision decisions; + private final ReviewSnapshotArchive archive; + private final ReviewRepairJournal journal; + private final RawReviewSnapshotReader reader; + private final Supplier configuration; + private final MongoCollection projects,admissions,operations; + public ReviewReplacementActivator(MongoTemplate mongo,ReviewReplacementActivationDecision decisions,ReviewSnapshotArchive archive, + ReviewRepairJournal journal,RawReviewSnapshotReader reader,Supplier configuration) { + this.mongo=Objects.requireNonNull(mongo);this.decisions=Objects.requireNonNull(decisions);this.archive=Objects.requireNonNull(archive); + this.journal=Objects.requireNonNull(journal);this.reader=Objects.requireNonNull(reader);this.configuration=Objects.requireNonNull(configuration); + projects=mongo.getCollection("projects");admissions=mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS); + operations=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Result activate(ReviewReplacementActivationDecision.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var previous=receipt(prepared,actor,permitted); + if(!"UNKNOWN".equals(previous.state()))return previous; + var context=decisions.verifyCurrent(prepared,actor,permitted);var source=context.decision(); + requireConfiguration(context);permission(permitted); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw new IllegalStateException("Activation requires transaction support"); + var claim=journal.claim(new ReviewRepairPreparation.Prepared(source.id(),digest(source.versionBytes()),source.createdAt(),source.expiresAt()), + actor,ReviewSnapshotArchive.Action.REPLACEMENT_ACTIVATION,permitted); + if(claim==null)return receipt(prepared,actor,permitted); + boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(permitted); + var op=ReviewRepairIo.collection(operations,session).find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")) + .collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(op==null)throw new IllegalStateException("Activation claim changed"); + var current=reader.capture(session,source.projectId(),source.versionIndex(),context.replacement().versionId()); + var admission=ReviewRepairIo.collection(admissions,session).find(session,new Document("_id",prepared.replacementId())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + String state="NOT_APPLIED",after=null; + if(current.sha256().equals(prepared.heldSha256()) && admission!=null && Arrays.equals(bytes(admission),bytes(context.admission()))) { + String prefix="versions."+source.versionIndex()+".scanResult."; + var query=new Document("_id",source.projectId()).append("status",new Document("$in",STATUSES)).append("$expr",new Document("$and",List.of( + new Document("$eq",List.of(new Document("$type","$status"),"string")), + new Document("$gte",List.of("$$NOW",new Date(source.createdAt()))),new Document("$lt",List.of("$$NOW",new Date(source.expiresAt())))))); + requireConfiguration(context);permission(permitted); + if(ReviewRepairIo.collection(projects,session).updateOne(session,query,new Document("$set",new Document(prefix+"scanState","REMOTE_REVIEW") + .append(prefix+"scanTimestamp",System.currentTimeMillis())),new UpdateOptions().collation(BINARY)).getModifiedCount()==1) { + after=reader.capture(session,source.projectId(),source.versionIndex(),context.replacement().versionId()).sha256(); + var fields=new Document("state","ACTIVE").append("activationId",prepared.id()).append("activatedSha256",after); + if(ReviewRepairIo.collection(admissions,session).updateOne(session,admission,new Document("$set",fields),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1) + throw new IllegalStateException("Admission changed"); + state="APPLIED"; + } + } + var fields=new Document("state",state).append("afterSha256",after).append("replacementId",prepared.replacementId()); + if(ReviewRepairIo.collection(operations,session).updateOne(session,op,List.of(new Document("$set",fields.append("finishedAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1) + throw new IllegalStateException("Activation claim changed"); + requireConfiguration(context);permission(permitted);commitAttempted=true;session.commitTransaction();return new Result(state,after); + } catch(RuntimeException failure) { + if(!commitAttempted)try{session.abortTransaction();}catch(RuntimeException ignored){} + throw failure; + } + } catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + if(uncertain instanceof SecurityException) {try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try{var observed=receipt(prepared,actor,permitted);if(!"UNKNOWN".equals(observed.state()))return observed;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){} + return new Result("UNKNOWN",null); + } + } + } + public Result receipt(ReviewReplacementActivationDecision.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted); + if(!prepared.equals(decisions.recover(prepared.id(),actor,permitted)))throw new IllegalStateException("Activation decision changed"); + var source=archive.load(prepared.id()); + var op=ReviewRepairIo.collection(operations).find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(permitted); + if(op==null || !actor.equals(op.get("actor")) || !"REPLACEMENT_ACTIVATION".equals(op.get("action")) + || !prepared.replacementId().equals(op.get("replacementId")) || !digest(source.versionBytes()).equals(op.get("sha256")) + || !Long.valueOf(prepared.createdAt()).equals(op.get("createdAt")) || !Long.valueOf(prepared.expiresAt()).equals(op.get("expiresAt")) + || !(op.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(op.get("state")) && op.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + if("APPLIED".equals(op.get("state")) && op.get("afterSha256") instanceof String sha && sha.matches("[0-9a-f]{64}"))return new Result("APPLIED",sha); + return new Result("UNKNOWN",null); + } + private void requireConfiguration(ReviewReplacementActivationDecision.Context context) { + // Runtime wiring must supply local current configuration, without network IO inside the transaction. + var binding=context.replacement();var expected=new ReviewReplacementPreparation.Configuration(binding.policyVersion(),binding.reviewConfigSha256(),binding.origin()); + if(!expected.equals(configuration.get()))throw new IllegalStateException("Replacement service configuration changed"); + } + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + private static String digest(byte[] bytes){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw new IllegalStateException(invalid);}} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Replacement activation is not permitted");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementAdmissionReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementAdmissionReader.java new file mode 100644 index 000000000..5113232a0 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementAdmissionReader.java @@ -0,0 +1,52 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Bounded discovery only. A reference is not an authenticated receipt or permission to dispatch. */ +public final class ReviewReplacementAdmissionReader { + public static final String HELD_INDEX="review_replacement_state_id", REQUEST_INDEX="review_replacement_request"; + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + public record Page(List operationIds,String nextCursor) {} + private final MongoCollection admissions; + public ReviewReplacementAdmissionReader(MongoTemplate mongo) { + admissions=mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).withReadPreference(ReadPreference.primary()) + .withReadConcern(ReadConcern.MAJORITY).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public void initialize() { + var writes=admissions.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + writes.createIndex(new Document("state",1).append("_id",1),new IndexOptions().name(HELD_INDEX).collation(BINARY)); + writes.createIndex(new Document("requestId",1),new IndexOptions().name(REQUEST_INDEX).collation(BINARY)); + } + public Page held(String cursor,int limit,BooleanSupplier permitted) { + permission(permitted); + if(limit<1 || limit>64 || cursor!=null && !cursor.matches("r1\\."+UUID))throw new IllegalArgumentException("Invalid admission page"); + var id=new Document("$type","string").append("$regex","^"+UUID+"$");if(cursor!=null)id.append("$gt",cursor.substring(3)); + var query=new Document("state","HELD").append("_id",id) + .append("$expr",new Document("$eq",List.of(new Document("$type","$state"),"string"))); + var records=ReviewRepairIo.collection(admissions).find(query).hintString(HELD_INDEX).collation(BINARY) + .projection(new Document("_id",1)).sort(new Document("_id",1)).limit(limit+1).batchSize(limit+1) + .maxTime(5,TimeUnit.SECONDS).into(new ArrayList<>()); + permission(permitted); + var ids=records.stream().limit(limit).map(record->record.getString("_id")).toList(); + return new Page(ids,records.size()>limit?"r1."+ids.getLast():null); + } + /** Any matching durable reference, including malformed records, prevents treating the request as untracked. */ + public boolean hasRequest(String requestId,BooleanSupplier permitted) { + permission(permitted); + if(requestId==null || !requestId.matches(UUID))throw new IllegalArgumentException("Invalid admission request identity"); + var found=ReviewRepairIo.collection(admissions).find(new Document("requestId",requestId)).hintString(REQUEST_INDEX) + .collation(BINARY).projection(new Document("_id",1)).limit(1).maxTime(5,TimeUnit.SECONDS).first(); + permission(permitted);return found!=null; + } + private static void permission(BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Replacement admission access is not permitted"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementEvidenceReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementEvidenceReader.java new file mode 100644 index 000000000..3a873ab02 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementEvidenceReader.java @@ -0,0 +1,59 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.ProjectVersion; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** Current evidence and authenticated isolation history; no replacement or remote-work authority. */ +public final class ReviewReplacementEvidenceReader { + public record Evidence(ReviewRemoteTargetReader.Captured current,ReviewOrphanTargetResolver.Target isolation,ReviewReplacementHistoryReader.Link previousReplacement) {} + private final ReviewRemoteTargetReader current; + private final ReviewOrphanTargetResolver orphans; + private final ReviewReplacementHistoryReader history; + + public ReviewReplacementEvidenceReader(ReviewRemoteTargetReader current,ReviewOrphanTargetResolver orphans,ReviewReplacementHistoryReader history) { + this.current=Objects.requireNonNull(current);this.orphans=Objects.requireNonNull(orphans); + this.history=Objects.requireNonNull(history); + } + + public Evidence capture(Object projectId,int versionIndex,String versionId,BooleanSupplier permitted) { + permission(permitted); + var captured=current.capture(projectId,versionIndex,versionId); + permission(permitted); + var version=new RawBsonDocument(captured.snapshot().versionBytes()).decode(new DocumentCodec()); + var scan=version.get("scanResult",Document.class); + Object prior=version.get("reviewReplacement"); + if(prior==null)history.requireUntracked(captured.binding().requestId(),permitted); + var previous=prior==null?null:history.verifyHead(projectId,versionIndex,captured.binding(),prior,permitted); + Object reference=version.get("reviewIsolation"); + if(reference==null) { + if(scan!=null && "REMOTE_ISOLATED".equals(scan.get("scanState")))throw inconsistent(); + permission(permitted);return new Evidence(captured,null,previous); + } + ProjectVersion.ReviewIsolation isolation; + try { + if(!(reference instanceof Document raw) || !(raw.get("operationId") instanceof String id) + || !(raw.get("actorId") instanceof String actor) || !(raw.get("beforeSha256") instanceof String digest) + || !(raw.get("isolatedAt") instanceof java.util.Date date))throw inconsistent(); + isolation=new ProjectVersion.ReviewIsolation(id,actor,digest,date); + } catch(RuntimeException invalid) {throw inconsistent();} + // The saved actor selects historical evidence; only the caller's fresh permission grants access. + var original=orphans.resolve(isolation.operationId(),isolation.actorId()); + permission(permitted); + if(!original.projectId().equals(captured.snapshot().projectId()) + || original.versionIndex()!=captured.snapshot().versionIndex() + || !original.beforeSha256().equals(isolation.beforeSha256()) + || !original.binding().equals(captured.binding()))throw inconsistent(); + permission(permitted);return new Evidence(captured,original,previous); + } + + private static void permission(BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Review evidence access is not permitted"); + } + private static IllegalStateException inconsistent() { + return new IllegalStateException("Retained isolation history is unavailable or inconsistent"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementExecutor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementExecutor.java new file mode 100644 index 000000000..dbfed1c3a --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementExecutor.java @@ -0,0 +1,126 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Atomically stages a retained replacement. HELD admissions cannot dispatch remote work. */ +public final class ReviewReplacementExecutor { + public static final String ADMISSIONS="review_replacement_admissions"; + public record Result(String state,String afterSha256) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT) + .readPreference(ReadPreference.primary()).writeConcern(WriteConcern.MAJORITY.withJournal(true)) + .maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + private final MongoTemplate mongo; + private final ReviewReplacementPreparation preparation; + private final RawReviewSnapshotReader reader; + private final ReviewRepairJournal journal; + private final MongoCollection projects,operations,admissions; + + public ReviewReplacementExecutor(MongoTemplate mongo,ReviewReplacementPreparation preparation, + RawReviewSnapshotReader reader,ReviewRepairJournal journal) { + this.mongo=Objects.requireNonNull(mongo);this.preparation=Objects.requireNonNull(preparation); + this.reader=Objects.requireNonNull(reader);this.journal=Objects.requireNonNull(journal); + projects=mongo.getCollection("projects");operations=mongo.getCollection(ReviewRepairJournal.COLLECTION) + .withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + admissions=mongo.getCollection(ADMISSIONS); + } + + public Result stage(ReviewReplacementPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var source=verify(prepared,actor,permitted).before(); + var original=new RawBsonDocument(source.versionBytes()).decode(new DocumentCodec()); + Object priorHold=original.get("replacementSecurityHold"); + if(priorHold!=null && (!(priorHold instanceof String value) || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))) + throw new IllegalStateException("Replacement security hold is inconsistent"); + var binding=new Document();mongo.getConverter().write(prepared.replacement(),binding); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw new IllegalStateException("Replacement requires transaction support"); + permission(permitted); + var claim=journal.claim(new ReviewRepairPreparation.Prepared(source.id(),prepared.beforeSha256(),prepared.createdAt(),prepared.expiresAt()), + actor,ReviewSnapshotArchive.Action.REPLACE_REVIEW,permitted); + if(claim==null)return receipt(prepared,actor,permitted); + boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(permitted); + var op=ReviewRepairIo.collection(operations,session).find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")) + .collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(op==null)throw new IllegalStateException("Replacement claim changed"); + var current=reader.capture(session,source.projectId(),source.versionIndex(),original.getString("_id")); + String state="NOT_APPLIED",after=null; + if(Arrays.equals(source.versionBytes(),current.versionBytes())) { + var previousScan=original.get("scanResult",Document.class); + var scan=previousScan==null ? new Document() : new Document(previousScan); + Object hold=priorHold!=null?priorHold:"BLOCK".equals(scan.get("verdict")) || "INFECTED".equals(scan.get("status"))?prepared.id():null; + scan.put("status","SCANNING");scan.put("scanState","REPLACEMENT_HELD"); + scan.put("scanRequestId",prepared.replacement().requestId());scan.put("scanAttempt",prepared.replacement().attempt()); + scan.put("manualRescan",true);scan.put("remoteReview",binding);scan.remove("remotePoll");scan.remove("remoteStatus"); + scan.put("verdict",hold!=null?"BLOCK":"REVIEW");scan.put("artifactVerified",false); + for(String field:List.of("securityEvidence","reviewedContextSha256","reusedReviewVersion","reusedReviewOrigins"))scan.put(field,null); + scan.put("reusedReviewApprovedAt",0L); + var fields=new Document("scanResult",scan).append("reviewStatus","PENDING").append("scheduledPublishDate",null) + .append("replacementSecurityHold",hold) + .append("securityApprovedAt",0L) + .append("reviewReplacement",new Document("operationId",prepared.id()).append("beforeSha256",prepared.beforeSha256()).append("requestId",prepared.replacement().requestId())); + for(String field:List.of("securityApprovalProjectId","approvedSecurityEvidence","approvedSecurityContextSha256","approvedReviewOrigins","approvedFindingReviewHead"))fields.put(field,null); + String prefix="versions."+source.versionIndex()+".";var set=new Document();fields.forEach((key,value)->set.put(prefix+key,value)); + var query=new Document("_id",source.projectId()).append("$expr",new Document("$and",List.of( + new Document("$gte",List.of("$$NOW",new Date(prepared.createdAt()))), + new Document("$lt",List.of("$$NOW",new Date(prepared.expiresAt())))))); + permission(permitted); + if(ReviewRepairIo.collection(projects,session).updateOne(session,query,new Document("$set",set) + .append("$unset",new Document(prefix+"reviewIsolation","").append(prefix+"retainedRemoteReview","")),new UpdateOptions().collation(BINARY)).getModifiedCount()==1) { + after=reader.capture(session,source.projectId(),source.versionIndex(),original.getString("_id")).sha256(); + var admission=new Document("_id",prepared.id()).append("beforeArchiveId",source.id()).append("beforeSha256",prepared.beforeSha256()) + .append("afterSha256",after).append("actor",actor).append("projectId",source.projectId()).append("versionIndex",source.versionIndex()) + .append("requestId",prepared.replacement().requestId()).append("state","HELD"); + ReviewRepairIo.collection(admissions,session).insertOne(session,admission);state="APPLIED"; + } + } + var fields=new Document("state",state).append("afterSha256",after).append("replacementId",prepared.id()); + if(ReviewRepairIo.collection(operations,session).updateOne(session,op,List.of(new Document("$set",fields.append("finishedAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1) + throw new IllegalStateException("Replacement claim changed"); + permission(permitted);commitAttempted=true;session.commitTransaction();return new Result(state,after); + } catch(RuntimeException failure) { + if(!commitAttempted)try{session.abortTransaction();}catch(RuntimeException ignored){} + throw failure; + } + } catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + if(uncertain instanceof SecurityException) {try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try {var observed=receipt(prepared,actor,permitted);if(!observed.state().equals("UNKNOWN"))return observed;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){} + return new Result("UNKNOWN",null); + } + } + } + + public Result receipt(ReviewReplacementPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);verify(prepared,actor,permitted); + var op=ReviewRepairIo.collection(operations).find(new Document("_id",prepared.beforeArchiveId())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + permission(permitted); + if(op==null || !actor.equals(op.get("actor")) || !"REPLACE_REVIEW".equals(op.get("action")) + || !prepared.id().equals(op.get("replacementId")) || !prepared.beforeSha256().equals(op.get("sha256")) + || !Long.valueOf(prepared.createdAt()).equals(op.get("createdAt")) || !Long.valueOf(prepared.expiresAt()).equals(op.get("expiresAt")) + || !(op.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(op.get("state")) && op.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + if("APPLIED".equals(op.get("state")) && op.get("afterSha256") instanceof String sha && sha.matches("[0-9a-f]{64}"))return new Result("APPLIED",sha); + return new Result("UNKNOWN",null); + } + private ReviewReplacementPreparation.Recovered verify(ReviewReplacementPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + Objects.requireNonNull(prepared); + var recovered=preparation.recoverEvidence(prepared.id(),actor,permitted); + if(!prepared.equals(recovered.prepared()))throw new IllegalStateException("Replacement intent changed"); + return recovered; + } + private static void permission(BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Replacement staging is not permitted"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementHistoryReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementHistoryReader.java new file mode 100644 index 000000000..926886fc2 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementHistoryReader.java @@ -0,0 +1,72 @@ +package net.modtale.service.admin.review; + +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.RemoteReviewBinding; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** One authenticated history link per read; never recursively loads an unbounded replacement chain. */ +public final class ReviewReplacementHistoryReader { + public record Link(String operationId,String beforeArchiveId,String beforeSha256,String previousOperationId) {} + private final ReviewSnapshotArchive archive; + private final ReviewRemoteTargetReader targets; + private final MongoCollection operations; + private final ReviewReplacementAdmissionReader admissions; + public ReviewReplacementHistoryReader(MongoTemplate mongo,ReviewSnapshotArchive archive,ReviewRemoteTargetReader targets) { + this.archive=Objects.requireNonNull(archive);this.targets=Objects.requireNonNull(targets); + operations=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + admissions=new ReviewReplacementAdmissionReader(mongo); + } + + public void requireUntracked(String requestId,BooleanSupplier permitted) { + if(admissions.hasRequest(requestId,permitted))throw inconsistent(); + } + + public Link verifyHead(Object projectId,int versionIndex,RemoteReviewBinding current,Object rawReference,BooleanSupplier permitted) { + permission(permitted); + var reference=reference(rawReference); + var intent=archive.load(reference.operationId());permission(permitted); + // The archived actor is historical attribution, not the identity granting access now. + var recovered=ReviewReplacementPreparation.recoverEvidence(archive,targets,reference.operationId(),intent.actorId(),permitted); + var prepared=recovered.prepared();var before=recovered.before(); + var expected=prepared.replacement(); + if(current.jobId()!=null)expected=expected.withJobId(current.jobId()); + if(!projectId.equals(before.projectId()) || versionIndex!=before.versionIndex() + || !reference.beforeSha256().equals(prepared.beforeSha256()) || !reference.requestId().equals(prepared.replacement().requestId()) + || !expected.equals(current))throw inconsistent(); + var op=ReviewRepairIo.collection(operations).find(new Document("_id",before.id())) + .collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + permission(permitted); + if(op==null || !"APPLIED".equals(op.get("state")) || !"REPLACE_REVIEW".equals(op.get("action")) + || !prepared.id().equals(op.get("replacementId")) || !before.actorId().equals(op.get("actor")) + || !prepared.beforeSha256().equals(op.get("sha256")) || !Long.valueOf(prepared.createdAt()).equals(op.get("createdAt")) + || !Long.valueOf(prepared.expiresAt()).equals(op.get("expiresAt")) || !(op.get("finishedAt") instanceof Date) + || !(op.get("afterSha256") instanceof String after) || !after.matches("[0-9a-f]{64}"))throw inconsistent(); + var original=new RawBsonDocument(before.versionBytes()).decode(new DocumentCodec()); + Object previous=original.get("reviewReplacement"); + String previousId=previous==null?null:reference(previous).operationId(); + if(prepared.id().equals(previousId))throw inconsistent(); + permission(permitted);return new Link(prepared.id(),before.id(),prepared.beforeSha256(),previousId); + } + + private static ProjectVersion.ReviewReplacement reference(Object value) { + try { + if(!(value instanceof Document raw) || raw.size()!=3 || !(raw.get("operationId") instanceof String id) + || !(raw.get("beforeSha256") instanceof String sha) || !(raw.get("requestId") instanceof String request))throw inconsistent(); + return new ProjectVersion.ReviewReplacement(id,sha,request); + } catch(RuntimeException malformed) {throw inconsistent();} + } + private static void permission(BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Replacement history access is not permitted"); + } + private static IllegalStateException inconsistent(){return new IllegalStateException("Replacement history is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementJobAccounting.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementJobAccounting.java new file mode 100644 index 000000000..76dee57d6 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementJobAccounting.java @@ -0,0 +1,44 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** Durable observations of the archived original job. Observation alone never activates a replacement. */ +public final class ReviewReplacementJobAccounting implements AutoCloseable { + public static final String COLLECTION="review_replacement_job_observations"; + public record Receipt(String id,String replacementId,String state,ReviewOrphanCancellationJournal.Observation observation,Long receivedAt) {} + private final ReviewReplacementPreparation preparation; + private final ReviewReplacementExecutor executor; + private final ReviewRemoteTargetReader targets; + private final ReviewRemoteStatusObserver observer; + public ReviewReplacementJobAccounting(MongoTemplate mongo,ReviewReplacementPreparation preparation,ReviewReplacementExecutor executor, + ReviewRemoteTargetReader targets,RemoteReviewClient client,int concurrency,Duration budget) { + this.preparation=Objects.requireNonNull(preparation);this.executor=Objects.requireNonNull(executor);this.targets=Objects.requireNonNull(targets); + observer=new ReviewRemoteStatusObserver(mongo,COLLECTION,client,concurrency,budget); + } + public Receipt check(String id,ReviewReplacementPreparation.Prepared replacement,String actor,BooleanSupplier permitted) { + return receipt(replacement.id(),observer.check(id,allowed->target(replacement,actor,allowed),permitted)); + } + public Receipt receipt(String id,ReviewReplacementPreparation.Prepared replacement,String actor,BooleanSupplier permitted) { + return receipt(replacement.id(),observer.receipt(id,allowed->target(replacement,actor,allowed),permitted)); + } + private ReviewRemoteStatusObserver.Target target(ReviewReplacementPreparation.Prepared replacement,String actor,BooleanSupplier permitted) { + var recovered=preparation.recoverEvidence(replacement.id(),actor,permitted); + if(!replacement.equals(recovered.prepared()) || !"APPLIED".equals(executor.receipt(replacement,actor,permitted).state())) + throw new IllegalStateException("Replacement has not been staged with retained evidence"); + var before=recovered.before();var original=targets.validate(before.projectId(),new RawBsonDocument(before.versionBytes()).decode(new DocumentCodec())); + var intent=new Document("actor",actor).append("replacementId",replacement.id()).append("beforeArchiveId",replacement.beforeArchiveId()) + .append("beforeSha256",replacement.beforeSha256()).append("createdAt",replacement.createdAt()).append("expiresAt",replacement.expiresAt()); + return new ReviewRemoteStatusObserver.Target(intent,original); + } + private static Receipt receipt(String replacementId,ReviewRemoteStatusObserver.Receipt result) { + return new Receipt(result.id(),replacementId,result.state(),result.observation(),result.receivedAt()); + } + @Override public void close(){observer.close();} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementPreparation.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementPreparation.java new file mode 100644 index 000000000..c5aae1ec6 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewReplacementPreparation.java @@ -0,0 +1,137 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.RemoteReviewBinding; +import net.modtale.model.project.RemoteReviewOrigin; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.*; +import java.util.function.BooleanSupplier; + +/** Signed proposal and evidence retention only; no queue admission, project mutation or remote requests. */ +public final class ReviewReplacementPreparation { + public record Configuration(String policyVersion,String reviewConfigSha256,RemoteReviewOrigin origin) { + public Configuration { + if(policyVersion==null || !policyVersion.matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || reviewConfigSha256==null || !reviewConfigSha256.matches("[0-9a-f]{64}") || origin==null) + throw new IllegalArgumentException("Invalid replacement configuration"); + } + } + public record Request(String id,Object projectId,int versionIndex,String versionId,String expectedSha256,String actorId,Configuration configuration) { + public Request { + new ReviewRepairPreparation.Request(id,projectId,versionIndex,versionId,expectedSha256,actorId,ReviewSnapshotArchive.Action.REPLACE_REVIEW); + Objects.requireNonNull(configuration); + } + } + public record Prepared(String id,String beforeArchiveId,String beforeSha256,long createdAt,long expiresAt, + RemoteReviewBinding replacement,String isolationId,String isolationBeforeSha256) {} + public record Recovered(Prepared prepared,ReviewSnapshotArchive.Snapshot before) {} + private final ReviewReplacementEvidenceReader evidence; + private final ReviewRemoteTargetReader targets; + private final RawReviewSnapshotReader snapshots; + private final ReviewSnapshotArchive archive; + private final Clock clock; + private final long lifetimeMillis; + + public ReviewReplacementPreparation(ReviewReplacementEvidenceReader evidence,ReviewRemoteTargetReader targets, + RawReviewSnapshotReader snapshots,ReviewSnapshotArchive archive,Clock clock,long lifetimeMillis) { + if(lifetimeMillis<1000 || lifetimeMillis>120000)throw new IllegalArgumentException("Invalid replacement lifetime"); + this.evidence=Objects.requireNonNull(evidence);this.targets=Objects.requireNonNull(targets); + this.snapshots=Objects.requireNonNull(snapshots);this.archive=Objects.requireNonNull(archive); + this.clock=Objects.requireNonNull(clock);this.lifetimeMillis=lifetimeMillis; + } + + public Prepared prepare(Request request,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(request); + var captured=evidence.capture(request.projectId(),request.versionIndex(),request.versionId(),permitted); + if(!captured.current().snapshot().sha256().equals(request.expectedSha256()))throw conflict(); + // Detect attempt exhaustion before retaining anything; arithmetic must never wrap to a reused attempt. + replacement(request.id(),captured.current().binding(),request.configuration()); + permission(permitted); + var before=archive.find(beforeId(request.id())); + if(before==null) { + long now=clock.millis(); + var candidate=captured.current().snapshot().forArchive(beforeId(request.id()),request.actorId(), + ReviewSnapshotArchive.Action.REPLACE_REVIEW,now,Math.addExact(now,lifetimeMillis)); + permission(permitted); + try {before=archive.retain(candidate);} + catch(RuntimeException uncertain) {before=archive.find(candidate.id());if(before==null)throw uncertain;} + } + permission(permitted); + if(!before.actorId().equals(request.actorId()) || before.action()!=ReviewSnapshotArchive.Action.REPLACE_REVIEW + || !before.projectId().equals(request.projectId()) || before.versionIndex()!=request.versionIndex() + || !Arrays.equals(before.versionBytes(),captured.current().snapshot().versionBytes()))throw conflict(); + live(before.createdAt(),before.expiresAt()); + var payload=new Document("schema",1).append("beforeArchiveId",before.id()).append("beforeSha256",request.expectedSha256()) + .append("policyVersion",request.configuration().policyVersion()).append("reviewConfigSha256",request.configuration().reviewConfigSha256()) + .append("deploymentId",request.configuration().origin().deploymentId()).append("callerScope",request.configuration().origin().callerScope()); + var intent=new ReviewSnapshotArchive.Snapshot(request.id(),request.projectId(),request.versionIndex(),request.actorId(), + ReviewSnapshotArchive.Action.REPLACEMENT_INTENT,before.createdAt(),before.expiresAt(),bytes(payload)); + permission(permitted); + // A fixed ID cannot select different configuration on retry, including a race with another preparation. + archive.retain(intent); + permission(permitted); + var prepared=recover(request.id(),request.actorId(),permitted); + if(!snapshots.isCurrent(captured.current().snapshot()))throw conflict(); + permission(permitted);live(prepared.createdAt(),prepared.expiresAt());return prepared; + } + + /** Recovery is read-only and remains possible after expiry or changes to the current version. */ + public Prepared recover(String id,String actor,BooleanSupplier permitted) { + return recoverEvidence(id,actor,permitted).prepared(); + } + public Recovered recoverEvidence(String id,String actor,BooleanSupplier permitted) { + return recoverEvidence(archive,targets,id,actor,permitted); + } + static Recovered recoverEvidence(ReviewSnapshotArchive archive,ReviewRemoteTargetReader targets,String id,String actor,BooleanSupplier permitted) { + permission(permitted); + var intent=archive.load(id);permission(permitted); + if(intent.action()!=ReviewSnapshotArchive.Action.REPLACEMENT_INTENT || !intent.actorId().equals(actor) + || intent.expiresAt()-intent.createdAt()>120000)throw conflict(); + var payload=new RawBsonDocument(intent.versionBytes()).decode(new DocumentCodec()); + if(payload.size()!=7 || !Integer.valueOf(1).equals(payload.get("schema")) + || !beforeId(id).equals(payload.get("beforeArchiveId")))throw conflict(); + var before=archive.load(beforeId(id));permission(permitted); + if(before.action()!=ReviewSnapshotArchive.Action.REPLACE_REVIEW || !before.actorId().equals(actor) + || !before.projectId().equals(intent.projectId()) || before.versionIndex()!=intent.versionIndex() + || before.createdAt()!=intent.createdAt() || before.expiresAt()!=intent.expiresAt() + || !digest(before.versionBytes()).equals(payload.get("beforeSha256")))throw conflict(); + var version=new RawBsonDocument(before.versionBytes()).decode(new DocumentCodec()); + var old=targets.validate(before.projectId(),version); + var configuration=new Configuration(payload.getString("policyVersion"),payload.getString("reviewConfigSha256"), + new RemoteReviewOrigin(payload.getString("deploymentId"),payload.getString("callerScope"))); + var isolation=version.get("reviewIsolation",Document.class); + var result=new Prepared(id,before.id(),payload.getString("beforeSha256"),intent.createdAt(),intent.expiresAt(), + replacement(id,old,configuration),isolation==null?null:isolation.getString("operationId"), + isolation==null?null:isolation.getString("beforeSha256")); + permission(permitted);return new Recovered(result,before); + } + + private static RemoteReviewBinding replacement(String id,RemoteReviewBinding old,Configuration config) { + String requestId=derived("request",id); + if(requestId.equals(old.requestId()))throw conflict(); + return new RemoteReviewBinding(old.projectId(),old.versionId(),requestId,Math.addExact(old.attempt(),1), + old.filePath(),old.artifactSha256(),old.contextSha256(),config.policyVersion(),config.reviewConfigSha256(),null,true,config.origin()); + } + private static String beforeId(String id){return derived("before",id);} + private static String derived(String purpose,String id) { + return UUID.nameUUIDFromBytes(("modtale-review-replacement-v1:"+purpose+":"+id).getBytes(StandardCharsets.UTF_8)).toString(); + } + private static byte[] bytes(Document document) { + var buffer=new RawBsonDocument(document,new DocumentCodec()).getByteBuffer().asNIO(); + byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes; + } + private static String digest(byte[] bytes) { + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + private void live(long created,long expires) { + long now=clock.millis();if(now=expires)throw conflict(); + } + private static void permission(BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Replacement preparation is not permitted"); + } + private static IllegalStateException conflict(){return new IllegalStateException("Replacement preparation changed or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java new file mode 100644 index 000000000..01b20e728 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java @@ -0,0 +1,118 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.Document; +import org.bson.types.Binary; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Immutable evidence storage only. A retained snapshot never authorizes a version mutation. */ +public final class ReviewSnapshotArchive { + public static final String METADATA="review_snapshot_archives", CHUNKS="review_snapshot_chunks"; + public static final int MAX_BYTES=16*1024*1024, CHUNK_BYTES=256*1024; + public enum Action { ISOLATE_REVIEW, REPLACE_REVIEW, CANCEL_REMOTE_REVIEW, REPLACEMENT_INTENT, REPLACEMENT_ACTIVATION, VERSION_MUTATION_BEFORE, VERSION_MUTATION_AFTER, VERSION_MUTATION_INTENT, PROJECT_MUTATION_BEFORE, PROJECT_MUTATION_AFTER, PROJECT_MUTATION_INTENT, PROJECT_MUTATION_APPLIED, PROJECT_MUTATION_ADMISSION } + public record Snapshot(String id,Object projectId,int versionIndex,String actorId,Action action,long createdAt,long expiresAt,byte[] versionBytes) { + public Snapshot { + if(!uuid(id) || !(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128 + && StandardCharsets.UTF_8.newEncoder().canEncode(s)) || versionIndex<0 || versionIndex>16*1024*1024 + || actorId==null || actorId.isBlank() || actorId.length()>256 || !StandardCharsets.UTF_8.newEncoder().canEncode(actorId) + || action==null || createdAt<=0 || expiresAt<=createdAt || expiresAt-createdAt>900000 + || versionBytes==null || versionBytes.length<5 || versionBytes.length>MAX_BYTES)throw new IllegalArgumentException("Invalid review snapshot"); + versionBytes=versionBytes.clone(); + } + @Override public byte[] versionBytes(){return versionBytes.clone();} + @Override public String toString(){return "ReviewSnapshot["+id+"]";} + } + private final MongoCollection metadata,chunks; + private final Map keys; + private final String activeKey; + public ReviewSnapshotArchive(MongoTemplate mongo,String activeKey,Map signingKeys) { + if(activeKey==null || signingKeys==null || signingKeys.isEmpty() || signingKeys.size()>8)throw new IllegalArgumentException("Snapshot signing keys are required"); + var copy=new HashMap(); + signingKeys.forEach((id,key)->{if(id==null || !id.matches("[A-Za-z0-9_-]{1,64}") || key==null || key.length<32 || key.length>128)throw new IllegalArgumentException("Invalid snapshot signing key");copy.put(id,key.clone());}); + if(!copy.containsKey(activeKey))throw new IllegalArgumentException("Active snapshot signing key is unavailable"); + this.activeKey=activeKey;keys=Map.copyOf(copy); + metadata=collection(mongo,METADATA);chunks=collection(mongo,CHUNKS); + } + private static MongoCollection collection(MongoTemplate mongo,String name) { + return mongo.getCollection(name).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,TimeUnit.SECONDS)); + } + public Snapshot retain(Snapshot snapshot) { + Objects.requireNonNull(snapshot); + var existing=read(metadata,snapshot.id()); + if(existing!=null) {var restored=load(snapshot.id());if(!same(snapshot,restored))throw unavailable();return restored;} + byte[] bytes=snapshot.versionBytes();int count=(bytes.length+CHUNK_BYTES-1)/CHUNK_BYTES; + for(int i=0;iMAX_BYTES || count!=(length+CHUNK_BYTES-1)/CHUNK_BYTES)throw unavailable(); + var bytes=new ByteArrayOutputStream(length); + for(int i=0;i collection,Document record) { + try {ReviewRepairIo.collection(collection).insertOne(record);} + catch(MongoException uncertain) { + // An acknowledged exact read can recover an inserted record, including lost write acknowledgements. + var restored=read(collection,record.getString("_id"));if(!record.equals(restored))throw unavailable(); + } + } + private static Document read(MongoCollection collection,String id) { + return ReviewRepairIo.collection(collection).find(new Document("_id",id)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + } + private static String tag(Document record,byte[] key) { + try { + var bytes=new ByteArrayOutputStream();var out=new DataOutputStream(bytes);out.writeUTF("modtale-review-snapshot-v1"); + out.writeUTF(record.getString("_id"));out.writeInt(record.getInteger("schema"));out.writeUTF(record.getString("keyId")); + Object id=record.get("projectId");out.writeBoolean(id instanceof ObjectId); + if(id instanceof ObjectId objectId)out.write(objectId.toByteArray());else out.writeUTF((String)id); + out.writeInt(record.getInteger("versionIndex"));out.writeUTF(record.getString("actorId"));out.writeUTF(record.getString("action")); + out.writeLong(record.getLong("createdAt"));out.writeLong(record.getLong("expiresAt"));out.writeInt(record.getInteger("length"));out.writeInt(record.getInteger("chunks"));out.writeUTF(record.getString("sha256")); + var mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(key,"HmacSHA256"));return HexFormat.of().formatHex(mac.doFinal(bytes.toByteArray())); + } catch(Exception invalid) {throw unavailable();} + } + private static String digest(byte[] bytes) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw unavailable();} + } + private static boolean same(Snapshot a,Snapshot b){return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() + && a.actorId().equals(b.actorId()) && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes());} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static IllegalStateException unavailable(){return new IllegalStateException("Review snapshot is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java new file mode 100644 index 000000000..b94812b01 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java @@ -0,0 +1,23 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewDiscovery; + +/** A scoped read position, not permission to modify the referenced record. */ +public final class ReviewStateDiagnosticCursor { + private ReviewStateDiagnosticCursor() {} + public static String encode(RemoteReviewDiscovery.Cursor cursor) { + if(cursor==null)return null; + return "d1."+(cursor.afterProject()?"a.":"v.")+ModerationQueueCursor.encode( + new ModerationQueuePageReader.Cursor(cursor.projectId(),cursor.offset())); + } + public static RemoteReviewDiscovery.Cursor decode(String token) { + if(token==null)return null; + if(token.length()>810 || !(token.startsWith("d1.a.1.") || token.startsWith("d1.v.1.")))throw new IllegalArgumentException("Invalid diagnostic cursor"); + try { + var position=ModerationQueueCursor.decode(token.substring(5)); + var cursor=new RemoteReviewDiscovery.Cursor(position.projectId(),Math.toIntExact(position.versionIndex()),token.charAt(3)=='a'); + if(!encode(cursor).equals(token))throw new IllegalArgumentException(); + return cursor; + } catch(IllegalArgumentException | ArithmeticException invalid) {throw new IllegalArgumentException("Invalid diagnostic cursor");} + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java new file mode 100644 index 000000000..9d0e0c7c3 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java @@ -0,0 +1,132 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.Project; +import net.modtale.model.project.RemoteReviewBinding; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Read-only structural diagnostics. Positions identify stored rows, never approval or retry authority. */ +@Service +public final class ReviewStateDiagnosticReader { + public enum Reason { INVALID_PROJECT_ID, INVALID_VERSION_ID, DUPLICATE_VERSION_ID, INVALID_REQUEST, + INVALID_ARTIFACT, INVALID_MANUAL_MODE, INVALID_SCAN_STATE, MISSING_BINDING, INVALID_BINDING, + BINDING_MISMATCH, INVALID_POLL, POLL_WITHOUT_REMOTE_STATE } + public record Item(Object projectId,int versionIndex,String versionId,List reasons) { + public Item { reasons=List.copyOf(reasons); } + } + public record Page(List items,RemoteReviewDiscovery.Cursor next,int examined) { + public Page { items=List.copyOf(items); } + } + private final MongoCollection projects; + public ReviewStateDiagnosticReader(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(RemoteReviewDiscovery.Cursor cursor,int limit) { + if(limit<1 || limit>64)throw new IllegalArgumentException("Invalid diagnostic page size"); + var scanning=new Document("reviewStatus","PENDING").append("scanResult.status","SCANNING"); + var terms=new ArrayList();terms.add(new Document("$cond",List.of(eq(type("$_id"),"string"), + new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true))); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))).append("versions",new Document("$elemMatch",scanning)) + .append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(eq("$_id",literal(cursor.projectId())),cursor.offset(),0)); + var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var view=new Document("scanning",new Document("$and",List.of(eq("$$v.reviewStatus","PENDING"),eq("$$v.scanResult.status","SCANNING")))) + .append("versionId",text("$$v._id",128)).append("filePath",text("$$v.fileUrl",4096)).append("hash",text("$$v.hash",64)) + .append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","sibling") + .append("cond",eq("$$sibling._id","$$v._id"))))); + String scan="$$v.scanResult"; + view.append("state",text(scan+".scanState",64)).append("request",text(scan+".scanRequestId",36)).append("attempt",integer(scan+".scanAttempt")) + .append("manualType",type(scan+".manualRescan")).append("manual",bool(scan+".manualRescan")) + .append("bindingType",type(scan+".remoteReview")).append("pollType",type(scan+".remotePoll")); + String binding=scan+".remoteReview"; + for(String key:List.of("projectId","versionId","requestId","filePath","artifactSha256","contextSha256","policyVersion","reviewConfigSha256","jobId")) + view.append("binding_"+key,text(binding+"."+key,key.equals("filePath")?4096:128)); + view.append("binding_attempt",integer(binding+".attempt")).append("binding_manualType",type(binding+".manualRescan")) + .append("binding_manual",bool(binding+".manualRescan")).append("binding_jobType",type(binding+".jobId")); + view.append("originType",type(binding+".origin")).append("originDeployment",text(binding+".origin.deploymentId",36)).append("originCaller",text(binding+".origin.callerScope",64)); + String poll=scan+".remotePoll"; + view.append("pollSize",new Document("$cond",List.of(eq(type(poll),"object"),new Document("$size",new Document("$objectToArray",poll)),0))) + .append("pollToken",text(poll+".token",36)).append("pollTokenType",type(poll+".token")) + .append("pollLeaseType",type(poll+".leaseUntil")).append("pollNextType",type(poll+".nextPollAt")); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",versions)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(versions,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=projects.aggregate(pipeline).collation(Collation.builder().locale("simple").build()).allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()) { + root=rows.hasNext()?rows.next():null; + } + if(root==null)return new Page(List.of(),null,0); + Object id=root.get("_id");String project=id instanceof ObjectId oid?oid.toHexString():(String)id; + var rows=root.getList("items",Document.class);var items=new ArrayList();int index=root.getInteger("offset"); + for(var row:rows) { + if(Boolean.TRUE.equals(row.get("scanning"))) { + var reasons=classify(project,row); + if(!reasons.isEmpty())items.add(new Item(id,index,validText(row.getString("versionId"),128)?row.getString("versionId"):null,reasons)); + } + index++; + } + var next=index classify(String project,Document row) { + var reasons=new ArrayList(); + if(!validText(project,128))reasons.add(Reason.INVALID_PROJECT_ID); + if(!validText(row.getString("versionId"),128))reasons.add(Reason.INVALID_VERSION_ID); + if(row.getInteger("idCount",0)>1)reasons.add(Reason.DUPLICATE_VERSION_ID); + if(!uuid(row.getString("request")) || row.getInteger("attempt",0)<1)reasons.add(Reason.INVALID_REQUEST); + if(!validText(row.getString("filePath"),4096) || !digest(row.getString("hash")))reasons.add(Reason.INVALID_ARTIFACT); + if(!manualType(row.getString("manualType")))reasons.add(Reason.INVALID_MANUAL_MODE); + String state=row.getString("state");boolean remote="REMOTE_REVIEW".equals(state); + if(!Set.of("QUEUED","SCANNING","REMOTE_REVIEW").contains(Objects.toString(state,"")))reasons.add(Reason.INVALID_SCAN_STATE); + String bindingType=row.getString("bindingType"); + if(Set.of("missing","null").contains(bindingType)) {if(remote)reasons.add(Reason.MISSING_BINDING);} + else { + RemoteReviewBinding binding=null; + try { + if(!"object".equals(bindingType) || !"bool".equals(row.getString("binding_manualType")) + || !Set.of("missing","null","string").contains(row.getString("binding_jobType")) + || "string".equals(row.getString("binding_jobType")) && !uuid(row.getString("binding_jobId")))throw new IllegalArgumentException(); + net.modtale.model.project.RemoteReviewOrigin origin=null; + if(!Set.of("missing","null").contains(row.getString("originType"))) { + if(!"object".equals(row.getString("originType")))throw new IllegalArgumentException(); + origin=new net.modtale.model.project.RemoteReviewOrigin(row.getString("originDeployment"),row.getString("originCaller")); + } + binding=new RemoteReviewBinding(row.getString("binding_projectId"),row.getString("binding_versionId"),row.getString("binding_requestId"),row.getInteger("binding_attempt",0), + row.getString("binding_filePath"),row.getString("binding_artifactSha256"),row.getString("binding_contextSha256"),row.getString("binding_policyVersion"), + row.getString("binding_reviewConfigSha256"),row.getString("binding_jobId"),Boolean.TRUE.equals(row.get("binding_manual")),origin); + } catch(IllegalArgumentException invalid) {reasons.add(Reason.INVALID_BINDING);} + if(binding!=null && (!remote || !project.equals(binding.projectId()) || !Objects.equals(row.getString("versionId"),binding.versionId()) + || !Objects.equals(row.getString("request"),binding.requestId()) || row.getInteger("attempt",0)!=binding.attempt() + || !Objects.equals(row.getString("filePath"),binding.filePath()) || !Objects.equals(row.getString("hash"),binding.artifactSha256()) + || Boolean.TRUE.equals(row.get("manual"))!=binding.manualRescan()))reasons.add(Reason.BINDING_MISMATCH); + } + String pollType=row.getString("pollType"); + if(!"missing".equals(pollType)) { + if(!remote)reasons.add(Reason.POLL_WITHOUT_REMOTE_STATE); + if(!"object".equals(pollType) || row.getInteger("pollSize",0)!=3 || !"date".equals(row.getString("pollLeaseType")) + || !"date".equals(row.getString("pollNextType")) || !("null".equals(row.getString("pollTokenType")) + || "string".equals(row.getString("pollTokenType")) && uuid(row.getString("pollToken"))))reasons.add(Reason.INVALID_POLL); + } + return reasons; + } + private static boolean manualType(String type){return "missing".equals(type) || "bool".equals(type);} + private static boolean validText(String s,int max){return s!=null && !s.isBlank() && s.length()<=max && s.chars().noneMatch(Character::isISOControl);} + private static boolean uuid(String s){return s!=null && s.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static boolean digest(String s){return s!=null && s.matches("[0-9a-f]{64}");} + private static Document type(String field){return new Document("$type",field);} + private static Document eq(Object a,Object b){return new Document("$eq",List.of(a,b));} + private static Document literal(Object v){return new Document("$literal",v);} + private static Document text(String field,int max){return new Document("$cond",Arrays.asList(eq(type(field),"string"),new Document("$substrCP",List.of(field,0,max+1)),null));} + private static Document bool(String field){return new Document("$cond",List.of(eq(type(field),"bool"),field,false));} + private static Document integer(String field){return new Document("$cond",List.of(new Document("$in",List.of(type(field),List.of("int","long"))), + new Document("$convert",new Document("input",field).append("to","int").append("onError",0).append("onNull",0)),0));} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionMutationExecutor.java b/backend/src/main/java/net/modtale/service/admin/review/VersionMutationExecutor.java new file mode 100644 index 000000000..3c4de2d0d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionMutationExecutor.java @@ -0,0 +1,127 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Internal single-version transaction. Runtime access and grouped owner mutations must supply their own authority. */ +public final class VersionMutationExecutor { + public static final String REFERENCES="version_mutation_references"; + public record Result(String state,String afterSha256) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).readPreference(ReadPreference.primary()) + .writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + private static final Set EDITABLE=Set.of("gameVersions","dependencies","incompatibleProjectIds","changelog","channel"); + private static final Set RESET=Set.of("scanResult","reviewStatus","scheduledPublishDate","approvedSecurityEvidence","approvedSecurityContextSha256", + "approvedReviewOrigins","approvedFindingReviewHead","securityApprovalProjectId","securityApprovedAt","approvedIssueBaselines"); + private final MongoTemplate mongo;private final VersionMutationPreparation preparation;private final ReviewSnapshotArchive archive; + private final ReviewRepairJournal journal;private final RawReviewSnapshotReader reader; + public VersionMutationExecutor(MongoTemplate mongo,VersionMutationPreparation preparation,ReviewSnapshotArchive archive,ReviewRepairJournal journal,RawReviewSnapshotReader reader) { + this.mongo=Objects.requireNonNull(mongo);this.preparation=Objects.requireNonNull(preparation);this.archive=Objects.requireNonNull(archive); + this.journal=Objects.requireNonNull(journal);this.reader=Objects.requireNonNull(reader); + } + public Result apply(VersionMutationPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var recovered=preparation.recover(prepared.id(),actor,permitted);if(!prepared.equals(recovered.prepared()))throw invalid(); + var previous=receipt(prepared,actor,permitted);if(!previous.state().equals("UNKNOWN"))return previous; + if(prepared.mutation()!=VersionMutationPreparation.Mutation.CONTEXT_EDIT && prepared.mutation()!=VersionMutationPreparation.Mutation.REMOVAL) + throw new IllegalArgumentException("This mutation requires grouped persistence"); + var before=recovered.before();var original=decode(before.versionBytes()); + var replacement=recovered.after()==null?null:projected(prepared,original,decode(recovered.after().versionBytes())); + if(replacement!=null && prepared.afterIndex()!=before.versionIndex())throw invalid(); + var source=archive.load(prepared.id()); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw invalid(); + var claim=journal.claim(new ReviewRepairPreparation.Prepared(source.id(),digest(source.versionBytes()),source.createdAt(),source.expiresAt()), + actor,ReviewSnapshotArchive.Action.VERSION_MUTATION_INTENT,permitted); + if(claim==null)return receipt(prepared,actor,permitted); + boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(permitted); + var operations=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION),session); + var operation=operations.find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")) + .collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();if(operation==null)throw invalid(); + var captured=reader.capture(session,before.projectId(),before.versionIndex(),original.getString("_id")); + var projects=ReviewRepairIo.collection(mongo.getCollection("projects"),session); + var project=projects.find(session,new Document("_id",before.projectId())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + String state="NOT_APPLIED",after=null; + if(Arrays.equals(captured.versionBytes(),before.versionBytes()) && project!=null + && project.get("status") instanceof String status && Set.of("DRAFT","PENDING","PUBLISHED","UNLISTED","PRIVATE").contains(status)) { + var versions=new ArrayList<>(project.getList("versions",Document.class)); + // A visible published project must keep at least one version; the outer grouped mutation handles replacement uploads. + if(replacement!=null || versions.size()>1 || Set.of("DRAFT","PRIVATE").contains(status)) { + if(replacement==null)versions.remove(before.versionIndex());else versions.set(before.versionIndex(),replacement); + var expression=new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",new Document("$literal",project))), + new Document("$gte",List.of("$$NOW",new Date(source.createdAt()))),new Document("$lt",List.of("$$NOW",new Date(source.expiresAt()))))); + permission(permitted); + var changed=projects.updateOne(session,new Document("_id",before.projectId()).append("$expr",expression), + new Document("$set",new Document("versions",versions).append("updatedAt",java.time.LocalDateTime.now().toString())),new UpdateOptions().collation(BINARY)); + if(changed.getModifiedCount()==1) { + after=replacement==null?null:reader.capture(session,before.projectId(),before.versionIndex(),original.getString("_id")).sha256(); + var reference=new Document("_id",prepared.id()).append("actor",actor).append("projectId",before.projectId()).append("versionId",original.get("_id")) + .append("beforeArchiveId",prepared.beforeArchiveId()).append("beforeSha256",prepared.beforeSha256()).append("afterSha256",after) + .append("state",replacement==null?"REMOVED":"HELD"); + ReviewRepairIo.collection(mongo.getCollection(REFERENCES),session).insertOne(session,reference);state="APPLIED"; + } + } + } + if(operations.updateOne(session,operation,List.of(new Document("$set",new Document("state",state).append("afterSha256",after).append("finishedAt","$$NOW"))), + new UpdateOptions().collation(BINARY)).getModifiedCount()!=1)throw invalid(); + permission(permitted);commitAttempted=true;session.commitTransaction();return new Result(state,after); + } catch(RuntimeException failure) {if(!commitAttempted)try{session.abortTransaction();}catch(RuntimeException ignored){}throw failure;} + } catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + if(uncertain instanceof SecurityException){try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try{var observed=receipt(prepared,actor,permitted);if(!observed.state().equals("UNKNOWN"))return observed;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}return new Result("UNKNOWN",null); + } + } + } + public Result receipt(VersionMutationPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);if(!prepared.equals(preparation.recover(prepared.id(),actor,permitted).prepared()))throw invalid(); + var source=archive.load(prepared.id()); + var op=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)) + .find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(permitted); + if(op==null || !actor.equals(op.get("actor")) || !source.action().name().equals(op.get("action")) || !digest(source.versionBytes()).equals(op.get("sha256")) + || !Long.valueOf(source.createdAt()).equals(op.get("createdAt")) || !Long.valueOf(source.expiresAt()).equals(op.get("expiresAt")) || !(op.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(op.get("state")) && op.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + if("APPLIED".equals(op.get("state")) && (prepared.mutation()==VersionMutationPreparation.Mutation.REMOVAL?op.get("afterSha256")==null: + op.get("afterSha256") instanceof String sha && sha.matches("[0-9a-f]{64}")))return new Result("APPLIED",op.getString("afterSha256")); + return new Result("UNKNOWN",null); + } + private static Document projected(VersionMutationPreparation.Prepared prepared,Document before,Document proposed) { + return projectVersion(prepared.id(),prepared.beforeSha256(),prepared.id(),before,proposed,prepared.createdAt()); + } + static Document projectVersion(String operationId,String beforeSha256,String requestSeed,Document before,Document proposed,long timestamp) { + var fields=new HashSet<>(before.keySet());fields.addAll(proposed.keySet()); + for(var field:fields)if(!EDITABLE.contains(field) && !RESET.contains(field) + && (before.containsKey(field)!=proposed.containsKey(field) || !Arrays.equals(bytes(new Document("v",before.get(field))),bytes(new Document("v",proposed.get(field))))))throw invalid(); + var next=new Document(before);for(var field:EDITABLE){if(proposed.containsKey(field))next.put(field,proposed.get(field));else next.remove(field);} + var old=before.get("scanResult",Document.class);Object hold=before.get("replacementSecurityHold"); + if(hold!=null && (!(hold instanceof String id) || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")))throw invalid(); + if(hold==null && old!=null && ("BLOCK".equals(old.get("verdict")) || "INFECTED".equals(old.get("status"))))hold=operationId; + String request=UUID.nameUUIDFromBytes(("version-mutation-request-1:"+requestSeed).getBytes(StandardCharsets.UTF_8)).toString(); + if(before.get("retainedRemoteReview")!=null && !(before.get("retainedRemoteReview") instanceof Document))throw invalid(); + if(old!=null && old.get("scanAttempt")==null)throw invalid(); + Object priorAttempt=old==null?before.get("retainedRemoteReview") instanceof Document retained?retained.get("attempt"):null:old.get("scanAttempt"); + if(priorAttempt!=null && (!(priorAttempt instanceof Integer || priorAttempt instanceof Long) || ((Number)priorAttempt).longValue()<1 || ((Number)priorAttempt).longValue()>=Integer.MAX_VALUE))throw invalid(); + int attempt=priorAttempt==null?1:Math.toIntExact(((Number)priorAttempt).longValue()+1); + var scan=new Document("status","SCANNING").append("scanState","MUTATION_HELD").append("scanRequestId",request) + .append("scanAttempt",attempt).append("scanTimestamp",timestamp).append("manualRescan",false).append("verdict",hold==null?"REVIEW":"BLOCK"); + next.put("scanResult",scan);for(var field:RESET)if(!field.equals("scanResult"))next.put(field,null); + next.put("reviewStatus","PENDING");next.put("securityApprovedAt",0L);next.put("replacementSecurityHold",hold); + next.put("versionMutation",new Document("operationId",operationId).append("beforeSha256",beforeSha256).append("requestId",request)); + return next; + } + private static Document decode(byte[] value){return new RawBsonDocument(value).decode(new DocumentCodec());} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception impossible){throw new IllegalStateException(impossible);}} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Version mutation is not permitted");} + private static IllegalStateException invalid(){return new IllegalStateException("Version mutation cannot be applied to the current state");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionMutationPreparation.java b/backend/src/main/java/net/modtale/service/admin/review/VersionMutationPreparation.java new file mode 100644 index 000000000..662759654 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionMutationPreparation.java @@ -0,0 +1,116 @@ +package net.modtale.service.admin.review; + +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.*; +import java.util.function.BooleanSupplier; + +/** Retains a proposed per-version owner mutation. Retention alone grants no mutation or review authority. */ +public final class VersionMutationPreparation { + public enum Mutation { CONTEXT_EDIT, SUBMISSION, REMOVAL, TARGET_REPLACEMENT } + public record Request(String id,Object projectId,int beforeIndex,String versionId,String expectedSha256,String actor, + Mutation mutation,int afterIndex,byte[] proposedVersion) { + public Request { proposedVersion=proposedVersion==null?null:proposedVersion.clone(); } + @Override public byte[] proposedVersion(){return proposedVersion==null?null:proposedVersion.clone();} + @Override public String toString(){return "VersionMutationRequest["+id+"]";} + } + public record Prepared(String id,String beforeArchiveId,String afterArchiveId,String beforeSha256,String afterSha256, + Mutation mutation,int afterIndex,Set changes,long createdAt,long expiresAt) { + public Prepared {changes=Set.copyOf(changes);} + } + public record Recovered(Prepared prepared,ReviewSnapshotArchive.Snapshot before,ReviewSnapshotArchive.Snapshot after) {} + private final RawReviewSnapshotReader reader; + private final ReviewSnapshotArchive archive; + private final Clock clock; + private final long lifetime; + public VersionMutationPreparation(RawReviewSnapshotReader reader,ReviewSnapshotArchive archive,Clock clock,long lifetime) { + if(lifetime<1000 || lifetime>120000)throw new IllegalArgumentException("Invalid mutation preparation lifetime"); + this.reader=Objects.requireNonNull(reader);this.archive=Objects.requireNonNull(archive);this.clock=Objects.requireNonNull(clock);this.lifetime=lifetime; + } + public Prepared prepare(Request request,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(request); + if(!uuid(request.id()) || request.actor()==null || request.actor().isBlank() || request.actor().length()>256 + || request.mutation()==null || request.expectedSha256()==null || !request.expectedSha256().matches("[0-9a-f]{64}"))throw invalid(); + var captured=reader.capture(request.projectId(),request.beforeIndex(),request.versionId());permission(permitted); + if(!captured.sha256().equals(request.expectedSha256()))throw invalid(); + var transition=classify(captured.versionBytes(),request.proposedVersion(),request.mutation(),request.afterIndex()); + if(!transition.requiresRetention())throw new IllegalArgumentException("This mutation does not require retained review history"); + String beforeId=derived(request.id(),"before"),afterId=request.proposedVersion()==null?null:derived(request.id(),"after"); + var existing=archive.find(beforeId);permission(permitted); + long created=existing==null?clock.millis():existing.createdAt(),expires=existing==null?Math.addExact(created,lifetime):existing.expiresAt(); + live(created,expires); + var before=captured.forArchive(beforeId,request.actor(),ReviewSnapshotArchive.Action.VERSION_MUTATION_BEFORE,created,expires); + retain(before,permitted); + if(afterId!=null)retain(new ReviewSnapshotArchive.Snapshot(afterId,request.projectId(),request.afterIndex(),request.actor(), + ReviewSnapshotArchive.Action.VERSION_MUTATION_AFTER,created,expires,request.proposedVersion()),permitted); + var payload=new Document("schema",1).append("beforeArchiveId",beforeId).append("afterArchiveId",afterId) + .append("beforeSha256",captured.sha256()).append("afterSha256",transition.afterSha256()) + .append("mutation",request.mutation().name()).append("afterIndex",request.afterIndex()); + retain(new ReviewSnapshotArchive.Snapshot(request.id(),request.projectId(),request.beforeIndex(),request.actor(), + ReviewSnapshotArchive.Action.VERSION_MUTATION_INTENT,created,expires,bytes(payload)),permitted); + if(!reader.isCurrent(captured))throw invalid();permission(permitted); + var result=recover(request.id(),request.actor(),permitted).prepared();live(result.createdAt(),result.expiresAt());return result; + } + /** Historical read-only recovery: never recaptures current state or refreshes expiry. */ + public Recovered recover(String id,String actor,BooleanSupplier permitted) { + permission(permitted);var intent=archive.load(id);permission(permitted); + if(intent.action()!=ReviewSnapshotArchive.Action.VERSION_MUTATION_INTENT || !intent.actorId().equals(actor) + || intent.expiresAt()-intent.createdAt()>120000)throw invalid(); + var payload=decode(intent.versionBytes()); + if(payload.size()!=7 || !Integer.valueOf(1).equals(payload.get("schema")) || !(payload.get("afterIndex") instanceof Integer index))throw invalid(); + var mutation=Mutation.valueOf(payload.getString("mutation")); + String beforeId=payload.getString("beforeArchiveId"),afterId=payload.getString("afterArchiveId"); + if(!derived(id,"before").equals(beforeId) || afterId!=null && !derived(id,"after").equals(afterId))throw invalid(); + var before=archive.load(beforeId);permission(permitted); + sameHeader(intent,before,ReviewSnapshotArchive.Action.VERSION_MUTATION_BEFORE,intent.versionIndex()); + var after=afterId==null?null:archive.load(afterId);permission(permitted); + if(after!=null)sameHeader(intent,after,ReviewSnapshotArchive.Action.VERSION_MUTATION_AFTER,index); + var transition=classify(before.versionBytes(),after==null?null:after.versionBytes(),mutation,index); + if(!transition.requiresRetention() || !Objects.equals(transition.beforeSha256(),payload.get("beforeSha256")) + || !Objects.equals(transition.afterSha256(),payload.get("afterSha256")))throw invalid(); + permission(permitted); + return new Recovered(new Prepared(id,beforeId,afterId,transition.beforeSha256(),transition.afterSha256(),mutation,index, + transition.changes(),intent.createdAt(),intent.expiresAt()),before,after); + } + private void retain(ReviewSnapshotArchive.Snapshot candidate,BooleanSupplier permitted) { + permission(permitted); + try {archive.retain(candidate);} + catch(RuntimeException uncertain) { + var saved=archive.find(candidate.id()); + if(saved==null || !same(candidate,saved))throw uncertain; + } + permission(permitted); + } + private static VersionReviewTransition.Transition classify(byte[] old,byte[] next,Mutation mutation,int afterIndex) { + if((mutation==Mutation.REMOVAL)!=(next==null) || (next==null?afterIndex!=-1:afterIndex<0 || afterIndex>16*1024*1024))throw invalid(); + var before=decode(old);var after=next==null?null:decode(next); + if(after!=null && !Objects.equals(before.get("_id"),after.get("_id")))throw invalid(); + var changes=VersionReviewTransition.classify(List.of(before),after==null?List.of():List.of(after)); + if(changes.size()!=1)throw invalid(); + return changes.getFirst(); + } + private static void sameHeader(ReviewSnapshotArchive.Snapshot intent,ReviewSnapshotArchive.Snapshot part,ReviewSnapshotArchive.Action action,int index) { + if(part.action()!=action || !part.projectId().equals(intent.projectId()) || part.versionIndex()!=index || !part.actorId().equals(intent.actorId()) + || part.createdAt()!=intent.createdAt() || part.expiresAt()!=intent.expiresAt())throw invalid(); + } + private static boolean same(ReviewSnapshotArchive.Snapshot a,ReviewSnapshotArchive.Snapshot b) { + return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() && a.actorId().equals(b.actorId()) + && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes()); + } + private void live(long created,long expires){long now=clock.millis();if(now=expires)throw invalid();} + private static Document decode(byte[] value) { + if(value==null || value.length<5 || value.length>ReviewSnapshotArchive.MAX_BYTES)throw invalid(); + var decoded=new RawBsonDocument(value).decode(new DocumentCodec()); + // Reject duplicate-field/trailing-byte representations instead of signing a lossy decoded proposal. + if(!Arrays.equals(value,bytes(decoded)))throw invalid(); + return decoded; + } + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + private static String derived(String id,String phase){return UUID.nameUUIDFromBytes(("version-mutation-1:"+id+":"+phase).getBytes(StandardCharsets.UTF_8)).toString();} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static IllegalStateException invalid(){return new IllegalStateException("Version mutation evidence is unavailable or inconsistent");} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Version mutation preparation is not permitted");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionMutationReferenceReader.java b/backend/src/main/java/net/modtale/service/admin/review/VersionMutationReferenceReader.java new file mode 100644 index 000000000..32f65c50d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionMutationReferenceReader.java @@ -0,0 +1,71 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Project-scoped discovery and authenticated historical reads; neither activates a held request. */ +public final class VersionMutationReferenceReader { + public static final String INDEX="version_mutation_project_id"; + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + public record Page(List operationIds,String nextCursor) {public Page{operationIds=List.copyOf(operationIds);}} + public record History(VersionMutationPreparation.Recovered evidence,VersionMutationExecutor.Result receipt,String state) {} + private final MongoCollection references; + private final ReviewSnapshotArchive archive; + private final VersionMutationPreparation preparation; + private final VersionMutationExecutor executor; + public VersionMutationReferenceReader(MongoTemplate mongo,ReviewSnapshotArchive archive,VersionMutationPreparation preparation,VersionMutationExecutor executor) { + this.archive=Objects.requireNonNull(archive);this.preparation=Objects.requireNonNull(preparation);this.executor=Objects.requireNonNull(executor); + references=mongo.getCollection(VersionMutationExecutor.REFERENCES).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withTimeout(5000,TimeUnit.MILLISECONDS); + } + public void initialize() { + references.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).createIndex(new Document("projectId",1).append("_id",1), + new IndexOptions().name(INDEX).collation(BINARY)); + } + public Page page(Object projectId,String cursor,int limit,BooleanSupplier permitted) { + permission(permitted);project(projectId);String scope=scope(projectId); + if(limit<1 || limit>64 || cursor!=null && !cursor.matches("m1\\."+scope+"\\."+UUID))throw invalid(); + var id=new Document("$type","string").append("$regex","^"+UUID+"$");if(cursor!=null)id.append("$gt",cursor.substring(68)); + var query=new Document("projectId",projectId).append("_id",id).append("state",new Document("$in",List.of("HELD","REMOVED"))) + .append("$expr",new Document("$and",List.of(new Document("$eq",List.of("$projectId",new Document("$literal",projectId))), + new Document("$eq",List.of(new Document("$type","$state"),"string"))))); + var rows=ReviewRepairIo.collection(references).find(query).hintString(INDEX).collation(BINARY).sort(new Document("_id",1)) + .projection(new Document("_id",1)).limit(limit+1).batchSize(limit+1).maxTime(5,TimeUnit.SECONDS).into(new ArrayList<>()); + permission(permitted);var ids=rows.stream().limit(limit).map(row->row.getString("_id")).toList(); + return new Page(ids,rows.size()>limit?"m1."+scope+"."+ids.getLast():null); + } + public History read(Object projectId,String id,BooleanSupplier permitted) { + permission(permitted);project(projectId);if(id==null || !id.matches(UUID))throw invalid(); + var reference=ReviewRepairIo.collection(references).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(permitted); + if(reference==null || !projectId.equals(reference.get("projectId")))throw unavailable(); + var intent=archive.load(id);permission(permitted); + // Historical attribution selects the record; the current caller's permission remains independently required. + var recovered=preparation.recover(id,intent.actorId(),permitted);var prepared=recovered.prepared();var before=recovered.before(); + if(!projectId.equals(before.projectId()))throw unavailable(); + var receipt=executor.receipt(prepared,intent.actorId(),permitted);if(!"APPLIED".equals(receipt.state()))throw unavailable(); + String state=prepared.mutation()==VersionMutationPreparation.Mutation.REMOVAL?"REMOVED":"HELD"; + var original=new RawBsonDocument(before.versionBytes()).decode(new DocumentCodec()); + var expected=new Document("_id",id).append("actor",intent.actorId()).append("projectId",projectId).append("versionId",original.get("_id")) + .append("beforeArchiveId",prepared.beforeArchiveId()).append("beforeSha256",prepared.beforeSha256()).append("afterSha256",receipt.afterSha256()).append("state",state); + if(!Arrays.equals(bytes(expected),bytes(reference)))throw unavailable(); + permission(permitted);return new History(recovered,receipt,state); + } + private static void project(Object id) { + if(!(id instanceof ObjectId || id instanceof String s && !s.isEmpty() && s.length()<=128 + && java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(s)))throw invalid(); + } + private static String scope(Object id){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes(new Document("projectId",id))));}catch(Exception invalid){throw new IllegalArgumentException("Invalid project scope",invalid);}} + private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Version mutation history access is not permitted");} + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid mutation history request");} + private static IllegalStateException unavailable(){return new IllegalStateException("Retained mutation history is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java new file mode 100644 index 000000000..36b1e6601 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java @@ -0,0 +1,106 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.model.Filters; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectVersion; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +@Service +public class VersionReviewPersistence { + private final MongoTemplate mongo; + public VersionReviewPersistence(MongoTemplate mongo) {this.mongo=mongo;} + public record Snapshot(Object projectId, Document version) {} + public Snapshot capture(String projectId, String versionId, String expectedToken) { + return capture(projectId, versionId, expectedToken, false); + } + public Snapshot captureForRescan(String projectId, String versionId, String expectedToken) { + return capture(projectId, versionId, expectedToken, true); + } + private Snapshot capture(String projectId, String versionId, String expectedToken, boolean rescan) { + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var filter=new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id",projectId),entity); + Document project=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(com.mongodb.ReadPreference.primary()) + .withReadConcern(com.mongodb.ReadConcern.MAJORITY).find(filter) + .collation(com.mongodb.client.model.Collation.builder().locale("simple").build()).maxTime(5,java.util.concurrent.TimeUnit.SECONDS).first(); + if(versionId==null || project==null || !(project.get("versions") instanceof List versions)) throw conflict(); + var matches=versions.stream().filter(item->item instanceof Document stored && versionId.equals(stored.get("_id"))).toList(); + if(matches.size()!=1)throw conflict(); + var stored=(Document)matches.getFirst(); + ProjectVersion version; + try {version=mongo.getConverter().read(ProjectVersion.class,stored);}catch(RuntimeException invalid){throw conflict();} + if (rescan) { + if (expectedToken == null || !expectedToken.equals(VersionReviewSnapshot.rescanToken(version))) throw conflict(); + } else VersionReviewSnapshot.requireCurrent(version,expectedToken); + return new Snapshot(project.get("_id"),stored); + } + public boolean apply(Snapshot snapshot, ProjectVersion reviewed) { + var originGuard = new org.springframework.data.mongodb.core.query.Query(); + var originalVersion = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + if (reviewed.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED) + net.modtale.service.security.issue.FindingReviewHistory.requireManualApproval(mongo, snapshot.projectId().toString(), originalVersion); + reviewed.setApprovedFindingReviewHead(reviewed.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED + ? originalVersion.getFindingReviewHead() : null); + var original = originalVersion.getScanResult(); + if (reviewed.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED && original != null && original.getReusedReviewVersion() != null + && (!net.modtale.service.security.scan.ArtifactClearancePolicy.complete(original) + || !net.modtale.service.security.scan.ArtifactReviewLineage.bind(mongo, snapshot.projectId().toString(), original, originGuard))) return false; + var update=new Update().set("versions.$.reviewStatus",reviewed.getReviewStatus()) + .set("versions.$.rejectionReason",reviewed.getRejectionReason()) + .set("versions.$.scheduledPublishDate",reviewed.getScheduledPublishDate()) + .set("versions.$.scanResult",reviewed.getScanResult()) + .set("versions.$.retainedRemoteReview", original!=null && original.getRemoteReview()!=null && reviewed.getScanResult()==null + ? original.getRemoteReview() : originalVersion.getRetainedRemoteReview()) + .set("versions.$.securityApprovalProjectId", reviewed.getSecurityApprovalProjectId()) + .set("versions.$.approvedReviewOrigins",reviewed.getApprovedReviewOrigins()) + .set("versions.$.approvedFindingReviewHead", reviewed.getApprovedFindingReviewHead()) + .set("versions.$.approvedSecurityEvidence",reviewed.getApprovedSecurityEvidence()) + .set("versions.$.approvedSecurityContextSha256",reviewed.getApprovedSecurityContextSha256()) + .set("versions.$.securityApprovedAt",reviewed.getSecurityApprovedAt()) + .set("versions.$.approvedIssueBaselines",reviewed.getApprovedIssueBaselines()) + .set("updatedAt",LocalDateTime.now().toString()); + return applyUpdate(snapshot, update, originGuard.getQueryObject()); + } + public boolean queueRescan(Snapshot snapshot, net.modtale.model.project.ScanResult queued) { + if (snapshot.version().get("versionMutation")!=null || snapshot.version().get("retainedRemoteReview")!=null || snapshot.version().get("reviewReplacement")!=null + || snapshot.version().get("scanResult") instanceof Document scan && scan.get("remoteReview") != null) + throw new ResponseStatusException(HttpStatus.CONFLICT, + "This version has a retained remote review. A replacement scan must preserve its review history first."); + return applyUpdate(snapshot, new Update() + .set("versions.$.scanResult", queued) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null) + .set("updatedAt", LocalDateTime.now().toString())); + } + public boolean appendFindingReview(Snapshot snapshot, String decisionId) { + return applyUpdate(snapshot, new Update().set("versions.$.findingReviewHead", decisionId) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null) + .set("updatedAt", LocalDateTime.now().toString())); + } + private boolean applyUpdate(Snapshot snapshot, Update update) { return applyUpdate(snapshot, update, new Document()); } + private boolean applyUpdate(Snapshot snapshot, Update update, Document originGuard) { + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var mapped=new UpdateMapper(mongo.getConverter()).getMappedObject(update.getUpdateObject(),entity); + if(!(snapshot.version().get("_id") instanceof String versionId))return false; + var unique=new Document("$eq",List.of(new Document("$size",new Document("$filter", + new Document("input",new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of()))) + .append("as","v").append("cond",new Document("$eq",List.of("$$v._id",new Document("$literal",versionId)))))),1)); + var filter=Filters.and(Filters.eq("_id",snapshot.projectId()),new Document("versions",new Document("$eq",snapshot.version())),new Document("$expr",unique)); + if (!originGuard.isEmpty()) filter = Filters.and(filter, originGuard); + return mongo.getCollection(mongo.getCollectionName(Project.class)).withWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true) + .withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)).updateOne(filter,mapped, + new com.mongodb.client.model.UpdateOptions().collation(com.mongodb.client.model.Collation.builder().locale("simple").build())).getModifiedCount()>0; + } + public static ResponseStatusException conflict() { + return new ResponseStatusException(HttpStatus.CONFLICT,"This version changed while the decision was being applied. Refresh its evidence before deciding."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java new file mode 100644 index 000000000..9ad5d4db5 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java @@ -0,0 +1,46 @@ +package net.modtale.service.admin.review; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import net.modtale.model.project.ProjectVersion; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +public final class VersionReviewSnapshot { + private static final ObjectMapper MAPPER=new ObjectMapper().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private static final ObjectMapper RESCAN_MAPPER = MAPPER.copy().addMixIn( + net.modtale.model.project.ScanResult.SecurityEvidence.class, RescanEvidence.class); + private abstract static class RescanEvidence { + @com.fasterxml.jackson.annotation.JsonIgnore abstract Map entryHashes(); + } + private VersionReviewSnapshot() {} + public static String token(ProjectVersion version) { + return token(version, MAPPER); + } + // Rescan requests need not load the old manifest; approval still uses the full evidence snapshot. + public static String rescanToken(ProjectVersion version) { + return token(version, RESCAN_MAPPER); + } + private static String token(ProjectVersion version, ObjectMapper mapper) { + if(version==null) throw new IllegalArgumentException("Missing review version"); + try { + @SuppressWarnings("unchecked") Map fields=mapper.convertValue(version,Map.class); + fields.remove("downloadCount"); + fields.put("replacementSecurityHold",version.getReplacementSecurityHold()); + fields.put("reviewReplacement",version.getReviewReplacement()); + fields.put("versionMutation",version.getVersionMutation()); + fields.put("retainedRemoteReview",version.getRetainedRemoteReview()); + var scan=version.getScanResult(); + fields.put("verifiedArtifact",scan!=null && scan.isArtifactVerified()); + fields.put("reusedOrigins",scan==null ? null : scan.getReusedReviewOrigins()); + fields.put("reviewedContext",scan==null ? null : scan.getReviewedContextSha256()); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(MAPPER.writeValueAsBytes(fields))); + } catch(Exception invalid) {throw new IllegalStateException("Could not bind the review snapshot",invalid);} + } + public static void requireCurrent(ProjectVersion version,String expected) { + if(expected==null || !expected.equals(token(version))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"This version changed after the review was opened. Refresh its evidence before deciding."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionReviewTransition.java b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewTransition.java new file mode 100644 index 000000000..f4fc2f177 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewTransition.java @@ -0,0 +1,109 @@ +package net.modtale.service.admin.review; + +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import java.security.MessageDigest; +import java.util.*; + +/** Classifies captured BSON without granting mutation, dispatch, or approval authority. */ +public final class VersionReviewTransition { + public enum Change { ADDED, REMOVED, ARTIFACT, CONTEXT, REVIEW, METADATA, UNKNOWN } + public record Transition(String versionId,int beforeIndex,int afterIndex,Set changes, + boolean priorHistory,String beforeSha256,String afterSha256) { + public Transition { changes=Set.copyOf(changes); } + public boolean requiresRetention() { + return beforeIndex>=0 && (changes.contains(Change.UNKNOWN) + || priorHistory && changes.stream().anyMatch(change -> change!=Change.METADATA && change!=Change.ADDED)); + } + } + private static final Set ARTIFACT=Set.of("fileUrl","hash","manifestId","manifestVersion","overrideFileUrl","modpackConfigs"); + private static final Set CONTEXT=Set.of("gameVersions","dependencies"); + private static final Set REVIEW=Set.of("versionMutation","scanResult","retainedRemoteReview","reviewReplacement","reviewIsolation","replacementSecurityHold", + "findingReviewHead","approvedFindingReviewHead","reviewStatus","rejectionReason","scheduledPublishDate","securityApprovalProjectId", + "approvedReviewOrigins","approvedSecurityEvidence","approvedSecurityContextSha256","securityApprovedAt","approvedIssueBaselines"); + private static final Set METADATA=Set.of("versionNumber","changelog","channel","incompatibleProjectIds","downloadCount","releaseDate"); + private VersionReviewTransition() {} + + /** Uses IDs rather than array positions for matching, retaining both positions for the eventual signed proposal. */ + public static List classify(List before,List after) { + var left=index(before);var right=index(after);var ids=new LinkedHashSet<>(left.keySet());ids.addAll(right.keySet()); + var result=new ArrayList(); + for(var id:ids) { + Integer oldIndex=left.get(id),newIndex=right.get(id); + Document old=oldIndex==null?null:before.get(oldIndex),next=newIndex==null?null:after.get(newIndex); + var changes=EnumSet.noneOf(Change.class); + if(old==null)changes.add(Change.ADDED); + else if(next==null)changes.add(Change.REMOVED); + else { + var fields=new HashSet<>(old.keySet());fields.addAll(next.keySet());fields.remove("_id"); + for(var field:fields) { + Object a=old.get(field),b=next.get(field); + boolean known=ARTIFACT.contains(field)||CONTEXT.contains(field)||REVIEW.contains(field)||METADATA.contains(field); + if(equal(field,a,b) && (known || old.containsKey(field)==next.containsKey(field)))continue; + changes.add(ARTIFACT.contains(field)?Change.ARTIFACT:CONTEXT.contains(field)?Change.CONTEXT: + REVIEW.contains(field)?Change.REVIEW:METADATA.contains(field)?Change.METADATA:Change.UNKNOWN); + } + } + result.add(new Transition(id,oldIndex==null?-1:oldIndex,newIndex==null?-1:newIndex,changes,history(old),digest(old),digest(next))); + } + return List.copyOf(result); + } + private static Map index(List versions) { + if(versions==null)throw new IllegalArgumentException("Missing version collection"); + bytes(new Document("versions",versions)); + var result=new LinkedHashMap(); + for(int i=0;i128 + || id.chars().anyMatch(Character::isISOControl) || result.put(id,i)!=null) + throw new IllegalArgumentException("Ambiguous version identity"); + } + return result; + } + private static boolean history(Document version) { + if(version==null)return false; + if(version.get("reviewStatus")!=null && !"PENDING".equals(version.get("reviewStatus")))return true; + if(version.get("scheduledPublishDate")!=null || version.get("rejectionReason")!=null)return true; + // Malformed non-null records still count as history; classification must never erase them as absent. + for(var field:REVIEW) { + if(Set.of("reviewStatus","rejectionReason","scheduledPublishDate","securityApprovedAt").contains(field))continue; + if(version.get(field)!=null)return true; + } + return version.get("securityApprovedAt")!=null && !Objects.equals(version.get("securityApprovedAt"),0L) + && !Objects.equals(version.get("securityApprovedAt"),0); + } + private static boolean equal(String field,Object left,Object right) { + if("gameVersions".equals(field) && strings(left) && strings(right)) + return sorted(left).equals(sorted(right)); + if("dependencies".equals(field) && documents(left) && documents(right)) + return sortedDocuments(left).equals(sortedDocuments(right)); + // BSON equality here preserves numeric types. Field order in embedded documents is conservatively significant. + return Arrays.equals(bytes(new Document("value",left)),bytes(new Document("value",right))); + } + private static boolean documents(Object value) { + return value==null || value instanceof List list && list.stream().allMatch(item -> item instanceof Document); + } + private static List sortedDocuments(Object value) { + var copy=new ArrayList(); + if(value instanceof List list)for(var item:list)copy.add(HexFormat.of().formatHex(bytes((Document)item))); + Collections.sort(copy);return copy; + } + private static boolean strings(Object value) { + return value==null || value instanceof List list && list.stream().allMatch(item -> item instanceof String); + } + private static List sorted(Object value) { + var copy=new ArrayList();if(value instanceof List list)for(var item:list)copy.add((String)item); + Collections.sort(copy);return copy; + } + private static String digest(Document value) { + if(value==null)return null; + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes(value)));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + private static byte[] bytes(Document value) { + var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO(); + if(buffer.remaining()>ReviewSnapshotArchive.MAX_BYTES)throw new IllegalArgumentException("Version transition exceeds snapshot limit"); + byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes; + } +} diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java index b509a32ca..f0cac1bde 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java @@ -52,6 +52,10 @@ public void privateProject(String id, User user) { projectPublicationService.privateProject(id, user); } + public void publishProject(String id, User user, String reviewToken, String versionId) { + projectPublicationService.publishProject(id, user, reviewToken, versionId); + } + public void publishProject(String id, User user) { projectPublicationService.publishProject(id, user); } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java index 0fdba484f..824893344 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java @@ -8,6 +8,8 @@ import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.analytics.ScoringService; import net.modtale.service.analytics.TrackingService; import net.modtale.service.project.query.ProjectService; @@ -20,6 +22,7 @@ @Service public class ProjectDeletionService { + private final ProjectReviewPersistence reviewPersistence; private final ProjectRepository projectRepository; private final ProjectService projectService; private final TrackingService trackingService; @@ -33,9 +36,11 @@ public ProjectDeletionService( TrackingService trackingService, ScoringService scoringService, ProjectArtifactDeletionService projectArtifactDeletionService, - MongoTemplate mongoTemplate + MongoTemplate mongoTemplate, + ProjectReviewPersistence reviewPersistence ) { this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.trackingService = trackingService; this.scoringService = scoringService; @@ -44,11 +49,13 @@ public ProjectDeletionService( } public void softDelete(Project project) { + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ProjectStatus oldStatus = project.getStatus(); project.setStatus(ProjectStatus.DELETED); project.setDeletedAt(LocalDateTime.now()); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + if (!reviewPersistence.applyDeletionState(snapshot, false)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); if (oldStatus == ProjectStatus.PUBLISHED || oldStatus == ProjectStatus.UNLISTED || oldStatus == ProjectStatus.ARCHIVED) { trackingService.logDeletedProject(project.getId()); @@ -56,21 +63,29 @@ public void softDelete(Project project) { } public void restore(Project project, ProjectStatus targetStatus) { + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); project.setStatus(targetStatus); project.setDeletedAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + if (!reviewPersistence.applyDeletionState(snapshot, false)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void hardDelete(Project project) { + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (!projectRepository.findByDependency(project.getId()).isEmpty()) { + var removedMedia = mediaSnapshot(project); scrubProjectForDependencyResolution(project); - projectRepository.save(project); + if (!reviewPersistence.applyDeletionState(snapshot, true)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + projectArtifactDeletionService.deleteProjectMedia(removedMedia); return; } + if (!reviewPersistence.deleteProject(snapshot)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(project); trackingService.deleteProjectAnalytics(project.getId()); Set dependencyIds = new HashSet<>(); if (project.getVersions() != null) { @@ -93,8 +108,6 @@ public void hardDelete(Project project) { mongoTemplate.updateMulti(new Query(Criteria.where("likedModIds").is(project.getId())), new Update().pull("likedModIds", project.getId()), net.modtale.model.user.User.class); scoringService.markProjectRankingDirty(project.getId()); - projectRepository.delete(project); - projectService.evictProjectCache(project); dependencyIds.forEach(this::cleanupOrphanedDependency); } @@ -114,12 +127,20 @@ public void deleteStoredFile(String fileUrl) { projectArtifactDeletionService.deleteStoredFile(fileUrl); } + private Project mediaSnapshot(Project project) { + var media = new Project(); media.setId(project.getId()); + media.setImageUrl(project.getImageUrl()); media.setBannerUrl(project.getBannerUrl()); + media.setGalleryImages(project.getGalleryImages() == null ? new ArrayList<>() : new ArrayList<>(project.getGalleryImages())); + return media; + } + private void scrubProjectForDependencyResolution(Project project) { project.setTitle("Deleted Project"); project.setDescription("This project has been deleted."); project.setAbout("This project was deleted by the author but is retained for dependency resolution."); project.setSlug(null); - projectArtifactDeletionService.deleteProjectMedia(project); + project.setImageUrl(null); project.setBannerUrl(null); + project.setGalleryImages(new ArrayList<>()); project.setGalleryImageCaptions(new java.util.HashMap<>()); project.setTeamMembers(new ArrayList<>()); project.setTeamInvites(new ArrayList<>()); project.setProjectRoles(new ArrayList<>()); diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java index 7b992da2d..6e0bded50 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java @@ -15,6 +15,8 @@ import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.repository.user.UserRepository; import net.modtale.service.communication.WebhookService; import net.modtale.service.project.access.ProjectAccessService; @@ -28,6 +30,9 @@ @Service public class ProjectDraftWorkflowService { + private final ProjectReviewPersistence reviewPersistence; + @org.springframework.beans.factory.annotation.Autowired(required=false) + private net.modtale.service.admin.review.ProjectMutationOwnerAccess retainedMutations; private final ProjectRepository projectRepository; private final ProjectService projectService; private final ValidationService validationService; @@ -49,9 +54,11 @@ public ProjectDraftWorkflowService( ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, VersionMutationOrchestrationService versionMutationOrchestrationService, - AppLimitProperties limitProperties + AppLimitProperties limitProperties, + ProjectReviewPersistence reviewPersistence ) { this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.validationService = validationService; this.webhookService = webhookService; @@ -148,7 +155,7 @@ public Project createDraft(String title, String description, ProjectClassificati project.setTeamMembers(new ArrayList<>()); project.setTeamInvites(new ArrayList<>()); - return projectRepository.save(project); + return projectRepository.insert(project); } public void submitProject(String id, User user) { @@ -156,6 +163,10 @@ public void submitProject(String id, User user) { "You do not have permission to submit this project."); requireVerifiedEmail(user, "submit a project for review"); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + if (project.getStatus() != ProjectStatus.DRAFT) + throw new InvalidProjectRequestException("Only draft projects can be submitted for review."); if (project.getVersions().isEmpty() && project.getClassification() != ProjectClassification.MODPACK) { throw new InvalidProjectRequestException("Add at least one version before submitting this project."); @@ -176,11 +187,24 @@ public void submitProject(String id, User user) { throw new InvalidProjectRequestException("Select a license before submitting this project."); } + if(retainedMutations!=null) { + var outcome=retainedMutations.submitDraft(snapshot.raw()); + if(!"APPLIED".equals(outcome.state()))throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(project); + var committed=projectRepository.findById(id).orElseThrow(ProjectReviewSnapshot::conflict); + if(committed.getVersions()==null || committed.getVersions().stream().noneMatch(version->version.getScanResult()!=null + && version.getScanResult().getStatus()==ScanStatus.SCANNING))webhookService.triggerAdminNewProjectWebhook(committed); + return; + } + if(project.getVersions()!=null && project.getVersions().stream().anyMatch(version->version.getRetainedRemoteReview()!=null + || version.getVersionMutation()!=null || version.getReviewReplacement()!=null || version.getReviewIsolation()!=null + || version.getReplacementSecurityHold()!=null || version.getScanResult()!=null && version.getScanResult().getRemoteReview()!=null)) + throw new InvalidProjectRequestException("Retained review history must be available before submitting this project."); project.setStatus(ProjectStatus.PENDING); project.setExpiresAt(null); List scansQueuedForSubmission = new ArrayList<>(); if (project.getVersions() != null) { - project.getVersions().forEach(version -> { + for (var version : project.getVersions()) { if (version.getReviewStatus() == null || version.getReviewStatus() == ProjectVersion.ReviewStatus.REJECTED) { version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); @@ -188,12 +212,12 @@ public void submitProject(String id, User user) { if (versionMutationOrchestrationService.queueSubmissionScanIfNeeded(project, version)) { scansQueuedForSubmission.add(version); } - }); + } } - projectRepository.save(project); + if (!reviewPersistence.submitDraft(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); - scansQueuedForSubmission.forEach(version -> versionMutationOrchestrationService.enqueueSubmissionScan(project, version)); + for (var version : scansQueuedForSubmission) versionMutationOrchestrationService.enqueueSubmissionScan(project, version); if (project.getVersions() == null || project.getVersions().stream().noneMatch(version -> version.getScanResult() != null && version.getScanResult().getStatus() == ScanStatus.SCANNING)) { diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java index ac7d48857..1b1178f88 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java @@ -1,13 +1,14 @@ package net.modtale.service.project.lifecycle; import java.time.LocalDateTime; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.exception.InvalidProjectRequestException; import net.modtale.exception.ProjectOperationForbiddenException; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.service.analytics.ScoringService; import net.modtale.service.analytics.TrackingService; import net.modtale.service.communication.ProjectNotificationService; @@ -21,7 +22,7 @@ @Service public class ProjectPublicationService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectNotificationService projectNotificationService; private final WebhookService webhookService; @@ -32,7 +33,6 @@ public class ProjectPublicationService { private final SecurityIssueAnalysisService securityIssueAnalysisService; public ProjectPublicationService( - ProjectRepository projectRepository, ProjectService projectService, ProjectNotificationService projectNotificationService, WebhookService webhookService, @@ -40,9 +40,10 @@ public ProjectPublicationService( ScoringService scoringService, AccessControlService accessControlService, ProjectAccessService projectAccessService, - SecurityIssueAnalysisService securityIssueAnalysisService + SecurityIssueAnalysisService securityIssueAnalysisService, + ProjectReviewPersistence reviewPersistence ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectNotificationService = projectNotificationService; this.webhookService = webhookService; @@ -56,19 +57,24 @@ public ProjectPublicationService( public void revertProjectToDraft(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_REVERT", "You do not have permission to revert this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.PENDING) { throw new InvalidProjectRequestException( "Only projects that are pending review can be reverted to draft."); } project.setStatus(ProjectStatus.DRAFT); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void archiveProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_ARCHIVE", "You do not have permission to archive this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.PUBLISHED && project.getStatus() != ProjectStatus.UNLISTED && project.getStatus() != ProjectStatus.PRIVATE) { @@ -77,38 +83,55 @@ public void archiveProject(String id, User user) { project.setStatus(ProjectStatus.ARCHIVED); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void unlistProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_UNLIST", "You do not have permission to unlist this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.PUBLISHED && project.getStatus() != ProjectStatus.ARCHIVED) { throw new InvalidProjectRequestException("Only published or archived projects can be unlisted."); } project.setStatus(ProjectStatus.UNLISTED); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void privateProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_UNLIST", "You do not have permission to make this project private."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() == ProjectStatus.PENDING || project.getStatus() == ProjectStatus.DELETED) { throw new InvalidProjectRequestException("Pending or deleted projects cannot be made private."); } project.setStatus(ProjectStatus.PRIVATE); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void publishProject(String id, User user) { - Project project = projectAccessService.requireProject(id); + publishProject(id, user, null, null); + } + + public void publishProject(String id, User user, String reviewToken, String reviewedVersionId) { + var snapshot = reviewToken == null ? null : reviewPersistence.capture(id, reviewToken); + boolean reviewed = snapshot != null; + if (snapshot == null) { + Project current = projectAccessService.requireProject(id); + snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(current)); + } + Project project = snapshot.project(); boolean canApproveReviews = accessControlService.canApproveProjectReviews(user); boolean isRestoration = project.getStatus() == ProjectStatus.ARCHIVED || project.getStatus() == ProjectStatus.UNLISTED @@ -122,20 +145,37 @@ public void publishProject(String id, User user) { } else if (!canApproveReviews) { throw new ProjectOperationForbiddenException("Only administrators with review approval permission can publish a new project."); } + if (!isRestoration && !reviewed) { + throw ProjectReviewSnapshot.conflict(); + } + if (reviewed && project.getStatus() != ProjectStatus.PENDING) { + throw new InvalidProjectRequestException("Only pending projects can be approved through review."); + } + ProjectVersion selected = null; + if (reviewed && reviewedVersionId != null && project.getVersions() != null) { + var matches = project.getVersions().stream().filter(version -> reviewedVersionId.equals(version.getId())).toList(); + if (matches.size() != 1) throw ProjectReviewSnapshot.conflict(); + selected = matches.getFirst(); + if (selected.getReviewStatus() != ProjectVersion.ReviewStatus.PENDING + && selected.getReviewStatus() != ProjectVersion.ReviewStatus.SCHEDULED + && selected.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED) { + throw new InvalidProjectRequestException("This version is not available for approval."); + } + } + if (reviewed && selected == null && project.getVersions() != null && !project.getVersions().isEmpty()) { + throw new InvalidProjectRequestException("Select the inspected version before publishing this project."); + } project.setStatus(ProjectStatus.PUBLISHED); project.setExpiresAt(null); project.setUpdatedAt(LocalDateTime.now().toString()); scoringService.markProjectRankingDirty(project); - if (project.getVersions() != null) { - project.getVersions().forEach(version -> { - if (version.getReviewStatus() == ProjectVersion.ReviewStatus.PENDING - || version.getReviewStatus() == ProjectVersion.ReviewStatus.SCHEDULED) { - version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); - version.setScheduledPublishDate(null); - } - }); - securityIssueAnalysisService.pruneApprovedScanResults(project); + if (selected != null && selected.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED) { + selected.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + selected.setScheduledPublishDate(null); + selected.setRejectionReason(null); + selected.setSecurityApprovalProjectId(project.getId()); + securityIssueAnalysisService.markIssuesAcceptedForApprovedVersion(selected); } if (isNew) { @@ -148,7 +188,8 @@ public void publishProject(String id, User user) { project.setImageUrl("https://modtale.net/assets/favicon.svg"); } - Project saved = projectRepository.save(project); + if (!reviewPersistence.apply(snapshot, reviewedVersionId)) throw ProjectReviewSnapshot.conflict(); + Project saved = project; projectService.evictProjectCache(saved); if (isNew) { @@ -162,10 +203,13 @@ public void publishProject(String id, User user) { public void updateProjectStatus(String id, ProjectStatus status, User user, String permissionRequired) { Project project = projectAccessService.requireProjectPermission(id, user, permissionRequired, "You do not have permission to update this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); project.setStatus(status); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java index 5baf5ff82..4412a90fa 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java @@ -1,72 +1,109 @@ package net.modtale.service.project.lifecycle; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import java.util.*; +import net.modtale.model.project.*; import net.modtale.service.communication.ProjectNotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.issue.SecurityIssueAnalysisService; +import net.modtale.service.security.scan.ArtifactClearancePolicy; +import net.modtale.service.security.scan.ArtifactReviewContext; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; import org.springframework.stereotype.Service; @Service public class ScheduledReleaseExecutionService { - - private final ProjectRepository projectRepository; + private final net.modtale.service.security.scan.WardenClientService warden; + private final MongoTemplate mongo; private final ProjectService projectService; - private final ProjectNotificationService projectNotificationService; - private final SecurityIssueAnalysisService securityIssueAnalysisService; + private final ProjectNotificationService notifications; + private final SecurityIssueAnalysisService issueAnalysis; - public ScheduledReleaseExecutionService( - ProjectRepository projectRepository, - ProjectService projectService, - ProjectNotificationService projectNotificationService, - SecurityIssueAnalysisService securityIssueAnalysisService - ) { - this.projectRepository = projectRepository; - this.projectService = projectService; - this.projectNotificationService = projectNotificationService; - this.securityIssueAnalysisService = securityIssueAnalysisService; + public ScheduledReleaseExecutionService(MongoTemplate mongo, ProjectService projectService, + ProjectNotificationService notifications, SecurityIssueAnalysisService issueAnalysis, + net.modtale.service.security.scan.WardenClientService warden) { + this.warden = warden; + this.mongo = mongo; this.projectService = projectService; + this.notifications = notifications; this.issueAnalysis = issueAnalysis; } - public List publishDueVersions(Project project, LocalDateTime publishTime) { - List releasedVersions = new ArrayList<>(); - if (project.getVersions() == null) { - return releasedVersions; - } - + List released = new ArrayList<>(); + if (project.getVersions() == null) return released; + if (project.getVersions().stream().noneMatch(version -> due(version, publishTime))) return released; + String currentPolicy = warden.currentPolicyVersion(); + if (currentPolicy == null) return released; for (ProjectVersion version : project.getVersions()) { - if (!isDueForRelease(version, publishTime)) { + if (!due(version, publishTime)) continue; + ScanResult scan = version.getScanResult(); + Criteria versionMatch = Criteria.where("_id").is(version.getId()) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.SCHEDULED) + .and("scheduledPublishDate").is(version.getScheduledPublishDate()) + .and("hash").is(version.getHash()) + .and("scanResult.scanAttempt").is(scan == null ? null : scan.getScanAttempt()) + .and("scanResult.remoteReview").is(scan == null ? null : scan.getRemoteReview()); + ArtifactReviewContext.bindSnapshot(versionMatch, version); + long now = System.currentTimeMillis(); + boolean valid = ArtifactClearancePolicy.boundToVersion(version) + && currentPolicy.equals(scan.getSecurityEvidence().policyVersion()) + && scan.getScanTimestamp() > 0 + && scan.getScanTimestamp() <= now + && now - scan.getScanTimestamp() < java.time.Duration.ofDays(30).toMillis(); + Update update = new Update().set("versions.$.scheduledPublishDate", null) + .set("updatedAt", publishTime.toString()); + Query originGuard = new Query(); + valid = valid && net.modtale.service.security.scan.ArtifactReviewLineage.bind(mongo, project.getId(), scan, originGuard); + if (valid) { + versionMatch.and("scanResult.securityEvidence.artifactSha256").is(scan.getSecurityEvidence().artifactSha256()) + .and("scanResult.securityEvidence.contentSha256").is(scan.getSecurityEvidence().contentSha256()) + .and("scanResult.reviewedContextSha256").is(scan.getReviewedContextSha256()) + .and("scanResult.securityEvidence.policyVersion").is(scan.getSecurityEvidence().policyVersion()) + .and("scanResult.scanTimestamp").is(scan.getScanTimestamp()) + .and("scanResult.verdict").is(scan.getVerdict()) + .and("scanResult.status").is(scan.getStatus()); + version.setSecurityApprovalProjectId(project.getId()); + issueAnalysis.markIssuesAcceptedForApprovedVersion(version); + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.APPROVED) + .set("versions.$.retainedRemoteReview", scan.getRemoteReview()!=null ? scan.getRemoteReview() : version.getRetainedRemoteReview()) + .set("versions.$.securityApprovalProjectId", version.getSecurityApprovalProjectId()) + .set("versions.$.approvedReviewOrigins", version.getApprovedReviewOrigins()) + .set("versions.$.approvedFindingReviewHead", null) + .set("versions.$.approvedSecurityEvidence", version.getApprovedSecurityEvidence()) + .set("versions.$.approvedSecurityContextSha256", version.getApprovedSecurityContextSha256()) + .set("versions.$.securityApprovedAt", version.getSecurityApprovedAt()) + .set("versions.$.approvedIssueBaselines", version.getApprovedIssueBaselines()) + .set("versions.$.scanResult", null); + } else { + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING); + if (scan != null && scan.getReusedReviewVersion() != null) { + net.modtale.service.security.scan.ArtifactReviewLineage.invalidate(scan); + update.set("versions.$.scanResult", scan); + } + } + Query query = new Query(Criteria.where("_id").is(project.getId()).and("versions").elemMatch(versionMatch)); + if (originGuard.getQueryObject().containsKey("$expr")) query.addCriteria(Criteria.where("$expr").is(originGuard.getQueryObject().get("$expr"))); + if (mongo.updateFirst(query, update, Project.class).getModifiedCount() == 0) { + if (valid && scan.getReusedReviewVersion() != null) { + net.modtale.service.security.scan.ArtifactReviewLineage.invalidate(scan); + var hold = new Update().set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scanResult", scan).set("versions.$.scheduledPublishDate", null); + var targetOnly = new Query(Criteria.where("_id").is(project.getId()).and("versions").elemMatch(versionMatch)); + if (mongo.updateFirst(targetOnly, hold, Project.class).getModifiedCount() > 0) projectService.evictProjectCache(project); + } continue; } - - version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); - version.setScheduledPublishDate(null); - releasedVersions.add(version.getVersionNumber()); - } - - if (releasedVersions.isEmpty()) { - return releasedVersions; + projectService.evictProjectCache(project); + if (valid) { + released.add(version.getVersionNumber()); + notifications.notifyUpdates(project, version.getVersionNumber()); + notifications.notifyDependents(project, version.getVersionNumber()); + } } - - project.setUpdatedAt(publishTime.toString()); - securityIssueAnalysisService.pruneApprovedScanResults(project); - projectRepository.save(project); - projectService.evictProjectCache(project); - releasedVersions.forEach(versionNumber -> { - projectNotificationService.notifyUpdates(project, versionNumber); - projectNotificationService.notifyDependents(project, versionNumber); - }); - return releasedVersions; + return released; } - - private boolean isDueForRelease(ProjectVersion version, LocalDateTime publishTime) { - if (version.getReviewStatus() != ProjectVersion.ReviewStatus.SCHEDULED - || version.getScheduledPublishDate() == null) { - return false; - } - return !LocalDateTime.parse(version.getScheduledPublishDate()).isAfter(publishTime); + private boolean due(ProjectVersion version, LocalDateTime time) { + if (version == null || version.getReviewStatus() != ProjectVersion.ReviewStatus.SCHEDULED || version.getScheduledPublishDate() == null) return false; + try { return !LocalDateTime.parse(version.getScheduledPublishDate()).isAfter(time); } + catch (java.time.format.DateTimeParseException ignored) { return true; } } } diff --git a/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java b/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java index 0c02f2f1b..ca266d93a 100644 --- a/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java +++ b/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java @@ -14,7 +14,8 @@ import net.modtale.exception.StorageUploadException; import net.modtale.model.project.Project; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.media.MediaUploadService; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; @@ -27,7 +28,7 @@ @Service public class ProjectMediaService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -39,7 +40,7 @@ public class ProjectMediaService { private static final Pattern YOUTUBE_VIDEO_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{11}$"); public ProjectMediaService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, @@ -48,7 +49,7 @@ public ProjectMediaService( FileValidationService fileValidationService, AppLimitProperties limitProperties ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -63,6 +64,8 @@ public void updateProjectImage(String id, MultipartFile file, User user, boolean Project project = projectAccessService.requireProjectPermission(id, user, permission, "You do not have permission to update this project's image."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); try { String currentUrl = isBanner ? project.getBannerUrl() : project.getImageUrl(); @@ -78,7 +81,7 @@ public void updateProjectImage(String id, MultipartFile file, User user, boolean project.setImageUrl(publicUrl); } - saveAndEvict(project); + saveAndEvict(snapshot); if (currentUrl != null && !currentUrl.equals(publicUrl) && !currentUrl.contains("default.png") && !currentUrl.contains("placeholder") && !currentUrl.contains("favicon")) projectDeletionService.deleteProjectMediaFile(project, currentUrl); } catch (StorageUploadException ex) { @@ -90,11 +93,13 @@ public Project addGalleryImage(String id, MultipartFile file, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to upload gallery images for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ensureGalleryCapacity(project); try { galleryItems(project).add(mediaUploadService.uploadPublicUrl(file, net.modtale.service.storage.ProjectMediaKeys.prefix(project.getId(), "gallery"), fileValidationService::validateGalleryImage)); - return saveAndEvict(project); + return saveAndEvict(snapshot); } catch (StorageUploadException ex) { throw new ProjectMediaOperationException(ex.getMessage(), ex); } @@ -104,6 +109,8 @@ public Project addGalleryVideo(String id, String videoUrl, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to add gallery videos for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ensureGalleryCapacity(project); String normalizedVideoUrl = normalizeYouTubeUrl(videoUrl); @@ -113,20 +120,23 @@ public Project addGalleryVideo(String id, String videoUrl, User user) { } galleryItems.add(normalizedVideoUrl); - return saveAndEvict(project); + return saveAndEvict(snapshot); } public Project removeGalleryImage(String id, String imageUrl, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_REMOVE", "You do not have permission to remove gallery images from this project."); projectMutationGuard.ensureEditable(project); - if (!galleryItems(project).remove(imageUrl)) throw new InvalidProjectRequestException("That gallery image does not exist on this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + if (!galleryItems(project).remove(imageUrl)) + throw new InvalidProjectRequestException("That gallery image does not exist on this project."); if (project.getGalleryImageCaptions() != null && project.getGalleryImageCaptions().containsKey(imageUrl)) { Map captions = new HashMap<>(project.getGalleryImageCaptions()); captions.remove(imageUrl); project.setGalleryImageCaptions(captions); } - var saved = saveAndEvict(project); + var saved = saveAndEvict(snapshot); if (!isYouTubeUrl(imageUrl)) projectDeletionService.deleteProjectMediaFile(project, imageUrl); return saved; } @@ -135,6 +145,8 @@ public Project updateGalleryImageCaption(String id, String imageUrl, String capt Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to edit gallery image captions for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getGalleryImages() == null || !project.getGalleryImages().contains(imageUrl)) { throw new InvalidProjectRequestException("That gallery image does not exist on this project."); @@ -156,16 +168,15 @@ public Project updateGalleryImageCaption(String id, String imageUrl, String capt } project.setGalleryImageCaptions(captions); - Project saved = projectRepository.save(project); - Project cacheTarget = saved != null ? saved : project; - projectService.evictProjectCache(cacheTarget); - return cacheTarget; + return saveAndEvict(snapshot); } public Project reorderGallery(String id, List imageUrls, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to reorder this project gallery."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); List currentItems = new ArrayList<>(galleryItems(project)); List requestedItems = imageUrls == null ? List.of() : new ArrayList<>(imageUrls); @@ -176,14 +187,13 @@ public Project reorderGallery(String id, List imageUrls, User user) { } project.setGalleryImages(requestedItems); - return saveAndEvict(project); + return saveAndEvict(snapshot); } - private Project saveAndEvict(Project project) { - Project saved = projectRepository.save(project); - Project cacheTarget = saved != null ? saved : project; - projectService.evictProjectCache(cacheTarget); - return cacheTarget; + private Project saveAndEvict(ProjectReviewPersistence.Snapshot snapshot) { + if (!reviewPersistence.applyPresentation(snapshot, true)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); + return snapshot.project(); } private void ensureGalleryCapacity(Project project) { diff --git a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java index 166a4e50f..7f962d725 100644 --- a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java +++ b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java @@ -8,6 +8,8 @@ import net.modtale.model.project.ProjectLicenseSupport; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.query.ProjectService; @@ -25,6 +27,7 @@ public class MetadataService { ); private static final Pattern GALLERY_CAROUSEL_MARKER_PATTERN = Pattern.compile("\\{\\{\\s*gallery-carousel\\s*\\}\\}", Pattern.CASE_INSENSITIVE); + private final ProjectReviewPersistence reviewPersistence; private final ProjectRepository projectRepository; private final ProjectService projectService; private final ValidationService validationService; @@ -34,6 +37,7 @@ public class MetadataService { public MetadataService( ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ValidationService validationService, ProjectAccessService projectAccessService, @@ -41,6 +45,7 @@ public MetadataService( SanitizationService sanitizer ) { this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.validationService = validationService; this.projectAccessService = projectAccessService; @@ -52,6 +57,8 @@ public void updateMetadata(String id, Project updated, User user) { Project existing = projectAccessService.requireProjectPermission(id, user, "PROJECT_EDIT_METADATA", "You do not have permission to edit this project's metadata."); projectMutationGuard.ensureEditable(existing); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(existing)); + existing = snapshot.project(); if (updated.getClassification() != null && updated.getClassification() != existing.getClassification()) { if (!MUTABLE_CLASSIFICATIONS.contains(existing.getClassification())) { @@ -107,7 +114,7 @@ else if (!newSlug.equals(existing.getSlug())) { if (updated.getImageUrl() != null && !java.util.Objects.equals(updated.getImageUrl(), existing.getImageUrl())) throw new InvalidProjectRequestException("Use the project image upload endpoint to change its image."); - projectRepository.save(existing); + if (!reviewPersistence.applyPresentation(snapshot, false)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(existing); } diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java b/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java index a5501ee51..3dc3079c2 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java @@ -9,8 +9,8 @@ import net.modtale.model.project.Project; import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; -import net.modtale.service.project.team.ProjectTeamPersistence; -import net.modtale.service.project.team.ProjectTeamSnapshot; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.repository.user.UserRepository; import net.modtale.service.auth.ApiKeyService; import net.modtale.service.project.access.ProjectAccessService; @@ -22,7 +22,7 @@ @Service public class TeamMembershipService { - private final ProjectTeamPersistence reviewPersistence; + private final ProjectReviewPersistence reviewPersistence; private final UserRepository userRepository; private final ProjectService projectService; private final ProjectAccessService projectAccessService; @@ -32,7 +32,7 @@ public class TeamMembershipService { private final AccessControlService accessControlService; public TeamMembershipService( - ProjectTeamPersistence reviewPersistence, + ProjectReviewPersistence reviewPersistence, UserRepository userRepository, ProjectService projectService, ProjectAccessService projectAccessService, @@ -55,7 +55,7 @@ public void inviteContributor(String id, String targetUserId, String roleId, Use Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_TEAM_INVITE", "You do not have permission to invite contributors to this project."); projectMutationGuard.ensureEditable(project); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); var role = requireRole(project, roleId); @@ -88,7 +88,7 @@ public void inviteContributor(String id, String targetUserId, String roleId, Use public void cancelInvite(String id, String targetUserId, String requestId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_TEAM_INVITE", "You do not have permission to manage contributor invites for this project."); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); var invite = ProjectInvitationPolicy.require(project, targetUserId, requestId, false); project.getTeamInvites().remove(invite); @@ -99,7 +99,7 @@ public void updateContributorRole(String id, String targetUserId, String roleId, Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to update contributor roles for this project."); projectMutationGuard.ensureEditable(project); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); Project.ProjectRole role = project.getProjectRoles().stream() @@ -122,7 +122,7 @@ public void removeContributor(String id, String targetUserId, User requester) { Project project = projectService.getRawProjectById(id); if (project != null && (accessControlService.hasProjectPermission(project, requester, "PROJECT_TEAM_REMOVE") || requester.getId().equals(targetUserId))) { projectMutationGuard.ensureEditable(project); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); boolean removed = project.getTeamMembers() != null && project.getTeamMembers().removeIf(member -> member.getUserId().equals(targetUserId)); saveProject(snapshot); @@ -139,7 +139,7 @@ public void acceptInvite(String id, String userId, String requestId) { if (project == null) { throw new ResourceNotFoundException("We couldn't find the project for that invite."); } - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); projectMutationGuard.ensureEditable(project); @@ -162,20 +162,20 @@ public void acceptInvite(String id, String userId, String requestId) { public void declineInvite(String id, String userId, String requestId) { Project project = projectService.getRawProjectById(id); if (project == null) throw new ResourceNotFoundException("Project not found."); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); var invite = ProjectInvitationPolicy.require(project, userId, requestId, false); project.getTeamInvites().remove(invite); saveInvitation(snapshot, userId, requestId, false); } - private void saveInvitation(ProjectTeamPersistence.Snapshot snapshot, String userId, String requestId, boolean accepting) { - if (!reviewPersistence.resolveContributorInvite(snapshot, userId, requestId, accepting)) throw ProjectTeamSnapshot.conflict(); + private void saveInvitation(ProjectReviewPersistence.Snapshot snapshot, String userId, String requestId, boolean accepting) { + if (!reviewPersistence.resolveContributorInvite(snapshot, userId, requestId, accepting)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(snapshot.project()); } - private void saveProject(ProjectTeamPersistence.Snapshot snapshot) { - if (!reviewPersistence.applyTeam(snapshot)) throw ProjectTeamSnapshot.conflict(); + private void saveProject(ProjectReviewPersistence.Snapshot snapshot) { + if (!reviewPersistence.applyTeam(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(snapshot.project()); } diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java b/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java index cf8b8df5b..4cb1acb10 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java @@ -7,8 +7,8 @@ import net.modtale.model.project.Project; import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; -import net.modtale.service.project.team.ProjectTeamPersistence; -import net.modtale.service.project.team.ProjectTeamSnapshot; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.auth.ApiKeyService; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.query.ProjectService; @@ -17,13 +17,13 @@ @Service public class TeamRoleService { - private final ProjectTeamPersistence reviewPersistence; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ApiKeyService apiKeyService; public TeamRoleService( - ProjectTeamPersistence reviewPersistence, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ApiKeyService apiKeyService @@ -37,7 +37,7 @@ public TeamRoleService( public Project createProjectRole(String id, String name, String color, Set perms, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to manage project roles."); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); if (project.getProjectRoles() == null) { project.setProjectRoles(new ArrayList<>()); @@ -54,7 +54,7 @@ public Project createProjectRole(String id, String name, String color, Set perms, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to manage project roles."); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); Project.ProjectRole role = project.getProjectRoles().stream() @@ -80,7 +80,7 @@ public Project updateProjectRole(String id, String roleId, String name, String c public Project deleteProjectRole(String id, String roleId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to manage project roles."); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); boolean inUse = (project.getTeamMembers() != null && project.getTeamMembers().stream().anyMatch(member -> roleId.equals(member.getRoleId()))) @@ -94,8 +94,8 @@ public Project deleteProjectRole(String id, String roleId, User requester) { return project; } - private void saveProject(ProjectTeamPersistence.Snapshot snapshot) { - if (!reviewPersistence.applyTeam(snapshot)) throw ProjectTeamSnapshot.conflict(); + private void saveProject(ProjectReviewPersistence.Snapshot snapshot) { + if (!reviewPersistence.applyTeam(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(snapshot.project()); } } diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java b/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java index 661f5a7ae..db634d227 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java @@ -9,8 +9,8 @@ import net.modtale.model.project.Project; import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; -import net.modtale.service.project.team.ProjectTeamPersistence; -import net.modtale.service.project.team.ProjectTeamSnapshot; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.repository.user.UserRepository; import net.modtale.service.auth.ApiKeyService; import net.modtale.service.project.access.ProjectAccessService; @@ -22,7 +22,7 @@ @Service public class TeamTransferService { - private final ProjectTeamPersistence reviewPersistence; + private final ProjectReviewPersistence reviewPersistence; private final UserRepository userRepository; private final ProjectService projectService; private final ProjectAccessService projectAccessService; @@ -32,7 +32,7 @@ public class TeamTransferService { private final AccessControlService accessControlService; public TeamTransferService( - ProjectTeamPersistence reviewPersistence, + ProjectReviewPersistence reviewPersistence, UserRepository userRepository, ProjectService projectService, ProjectAccessService projectAccessService, @@ -55,7 +55,7 @@ public void requestTransfer(String id, String targetUserId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_TRANSFER_REQUEST", "You do not have permission to transfer this project."); projectMutationGuard.ensureEditable(project); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); User target = userRepository.findById(targetUserId) @@ -68,7 +68,7 @@ public void requestTransfer(String id, String targetUserId, User requester) { project.setPendingTransferRequestId(java.util.UUID.randomUUID().toString()); project.setPendingTransferOwnerId(project.getAuthorId()); project.setPendingTransferExpiresAt(System.currentTimeMillis() + java.time.Duration.ofDays(7).toMillis()); - if (!reviewPersistence.applyTeam(snapshot)) throw ProjectTeamSnapshot.conflict(); + if (!reviewPersistence.applyTeam(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); User author = userRepository.findById(project.getAuthorId()).orElse(null); @@ -81,7 +81,7 @@ public void resolveTransfer(String id, boolean accept, String requestId, User re throw new InvalidProjectRequestException("We couldn't find a pending transfer request for that project."); } projectMutationGuard.ensureEditable(project); - var snapshot = reviewPersistence.capture(id, ProjectTeamSnapshot.token(project)); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); project = snapshot.project(); if (requestId == null || requestId.isBlank() || !requestId.equals(project.getPendingTransferRequestId()) @@ -112,7 +112,7 @@ public void resolveTransfer(String id, boolean accept, String requestId, User re project.getTeamMembers().removeIf(member -> member.getUserId().equals(newOwner.getId())); } - if (!reviewPersistence.resolveTransfer(snapshot, requestId)) throw ProjectTeamSnapshot.conflict(); + if (!reviewPersistence.resolveTransfer(snapshot, requestId)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); if (oldOwner != null) { if (oldOwner.getAccountType() == User.AccountType.ORGANIZATION && oldOwner.getOrganizationMembers() != null) { @@ -128,7 +128,7 @@ public void resolveTransfer(String id, boolean accept, String requestId, User re } project.setPendingTransferTo(null); - if (!reviewPersistence.resolveTransfer(snapshot, requestId)) throw ProjectTeamSnapshot.conflict(); + if (!reviewPersistence.resolveTransfer(snapshot, requestId)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); User oldOwner = userRepository.findById(project.getAuthorId()).orElse(null); teamNotificationService.sendTransferDeclined(project, oldOwner); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java index f5366356b..91fbbb6fa 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java @@ -16,7 +16,8 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.query.ProjectService; @@ -26,7 +27,9 @@ @Service public class VersionCreationCommandHandler { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; + @org.springframework.beans.factory.annotation.Autowired(required=false) + private net.modtale.service.admin.review.ProjectMutationOwnerAccess retainedMutations; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -34,14 +37,14 @@ public class VersionCreationCommandHandler { private final int maxVersionsPerDay; public VersionCreationCommandHandler( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, VersionMutationOrchestrationService versionMutationOrchestrationService, AppLimitProperties limitProperties ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -64,6 +67,8 @@ public void addVersion( Project project = projectAccessService.requireVersionPermission(projectId, user, "VERSION_CREATE", "You do not have permission to add a version to this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ensureProjectVersionList(project); versionMutationOrchestrationService.validateVersionNumber(versionNumber); @@ -74,6 +79,10 @@ public void addVersion( ensureProjectIsWithinDailyVersionLimit(project); ensureVersionNumberIsUnique(duplicateTargets, replaceExisting); + if(retainedMutations==null && duplicateTargets.stream().anyMatch(version->version.getRetainedRemoteReview()!=null || version.getVersionMutation()!=null + || version.getReviewReplacement()!=null || version.getReviewIsolation()!=null || version.getReplacementSecurityHold()!=null + || version.getScanResult()!=null && version.getScanResult().getRemoteReview()!=null)) + throw new InvalidVersionRequestException("Retained review history must be available before replacing this version."); VersionArtifactService.PreparedVersionArtifact preparedArtifact = versionMutationOrchestrationService.prepareVersionArtifact(project, file); boolean modpack = preparedArtifact.classification() == ProjectClassification.MODPACK; @@ -107,13 +116,20 @@ public void addVersion( project.setChildProjectIds(simpleProjectIds); } + List changedTargets = new ArrayList<>(); List replacedVersions = replaceExisting - ? replaceMatchingVersionTargets(project, versionNumber, gameVersions) + ? replaceMatchingVersionTargets(project, versionNumber, gameVersions, changedTargets) : List.of(); project.getVersions().add(0, version); - projectRepository.save(project); + if(retainedMutations!=null) { + var outcome=retainedMutations.editVersion(snapshot.raw(),reviewPersistence.versionUploadProposal(snapshot)); + if(!"APPLIED".equals(outcome.state()))throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(project);return; + } + if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); versionMutationOrchestrationService.enqueueInitialScan(project, version, file, modpack, preparedArtifact.filePath()); + for (var changed : changedTargets) versionMutationOrchestrationService.enqueueContextChangeScan(project, changed); replacedVersions.forEach(versionMutationOrchestrationService::deleteVersionFile); } @@ -186,7 +202,7 @@ && gameVersionsOverlap(version.getGameVersions(), gameVersions)) .toList(); } - private List replaceMatchingVersionTargets(Project project, String versionNumber, List gameVersions) { + private List replaceMatchingVersionTargets(Project project, String versionNumber, List gameVersions, List changedTargets) { List replacedVersions = new ArrayList<>(); for (ProjectVersion existing : new ArrayList<>(project.getVersions())) { if (existing.getVersionNumber() == null @@ -201,7 +217,11 @@ private List replaceMatchingVersionTargets(Project project, Stri continue; } + var previousScan = existing.getScanResult(); existing.setGameVersions(removeRequestedGameVersions(existing.getGameVersions(), gameVersions)); + existing.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + existing.setScheduledPublishDate(null); existing.setScanResult(null); + if (retainedMutations==null && versionMutationOrchestrationService.prepareContextChangeScan(project, existing, previousScan)) changedTargets.add(existing); } return replacedVersions; } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java index 864180e60..5eb9490d9 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java @@ -126,7 +126,7 @@ public void enqueueInitialScan(Project project, ProjectVersion version, Multipar filePath, file.getOriginalFilename(), false, - 1 + 1, version.getScanResult() == null ? null : version.getScanResult().getScanRequestId() ); } @@ -157,7 +157,7 @@ public void enqueueSubmissionScan(Project project, ProjectVersion version) { version.getFileUrl(), version.getFileUrl(), false, - 1 + 1, version.getScanResult() == null ? null : version.getScanResult().getScanRequestId() ); } @@ -168,6 +168,21 @@ private boolean shouldScanImmediately(Project project, MultipartFile file, boole && !modpack; } + public boolean prepareContextChangeScan(Project project, ProjectVersion version, ScanResult previous) { + if (project.getStatus() == ProjectStatus.DRAFT || project.getClassification() == ProjectClassification.MODPACK + || version.getFileUrl() == null || version.getFileUrl().isBlank()) return false; + int attempt = scanService.nextScanAttempt(previous); + version.setScanResult(scanService.createQueuedScanResult(attempt, "Runtime or dependencies changed; current review required.")); + return true; + } + + public void enqueueContextChangeScan(Project project, ProjectVersion version) { + scanService.enqueueBackgroundScan(project.getId(), version.getId(), version.getFileUrl(), version.getFileUrl(), + false, version.getScanResult().getScanAttempt(), version.getScanResult().getScanRequestId()); + } + + public void deleteCachedArtifact(String path) { projectDeletionService.deleteStoredFile(path); } + public void invalidateCachedModpackArtifact(ProjectVersion version, List dependencies) { if (version == null || dependencies == null) { return; diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionService.java b/backend/src/main/java/net/modtale/service/project/version/VersionService.java index 6c22b6fca..1b1b1f915 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionService.java @@ -10,7 +10,8 @@ import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.access.ProjectVersionAccessService; @@ -25,7 +26,9 @@ @Service public class VersionService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; + @org.springframework.beans.factory.annotation.Autowired(required=false) + private net.modtale.service.admin.review.ProjectMutationOwnerAccess retainedMutations; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -37,7 +40,7 @@ public class VersionService { private final VersionUpdateCommandHandler versionUpdateCommandHandler; public VersionService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, @@ -48,7 +51,7 @@ public VersionService( VersionCreationCommandHandler versionCreationCommandHandler, VersionUpdateCommandHandler versionUpdateCommandHandler ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -119,6 +122,8 @@ public void deleteVersion(String id, String versionId, User user) { Project project = projectAccessService.requireVersionPermission(id, user, "VERSION_DELETE", "You do not have permission to delete this version."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.DRAFT && project.getStatus() != ProjectStatus.PRIVATE && project.getVersions().size() <= 1) { @@ -127,10 +132,22 @@ public void deleteVersion(String id, String versionId, User user) { ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new VersionNotFoundException("We couldn't find that project version.")); - projectDeletionService.deleteVersionFile(version); + if (retainedMutations != null) { + var outcome=retainedMutations.removeVersion(snapshot.raw(),versionId); + if (!"APPLIED".equals(outcome.state())) throw ProjectReviewSnapshot.conflict(); + project.getVersions().removeIf(existing -> existing.getId().equals(versionId)); + projectService.evictProjectCache(project); + // Historical artifact references remain retained; visible deletion does not authorize storage cleanup. + return; + } + if (version.getRetainedRemoteReview()!=null || version.getVersionMutation()!=null || version.getReviewReplacement()!=null + || version.getReviewIsolation()!=null || version.getReplacementSecurityHold()!=null + || version.getScanResult()!=null && version.getScanResult().getRemoteReview()!=null) + throw new net.modtale.exception.InvalidVersionRequestException("Retained review history must be available before removing this version."); project.getVersions().removeIf(existing -> existing.getId().equals(versionId)); - projectRepository.save(project); + if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + projectDeletionService.deleteVersionFile(version); } } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java index d34165332..449bdcd90 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java @@ -8,7 +8,8 @@ import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.access.ProjectVersionAccessService; @@ -18,7 +19,9 @@ @Service public class VersionUpdateCommandHandler { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; + @org.springframework.beans.factory.annotation.Autowired(required=false) + private net.modtale.service.admin.review.ProjectMutationOwnerAccess retainedMutations; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -26,14 +29,14 @@ public class VersionUpdateCommandHandler { private final VersionMutationOrchestrationService versionMutationOrchestrationService; public VersionUpdateCommandHandler( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, ProjectVersionAccessService projectVersionAccessService, VersionMutationOrchestrationService versionMutationOrchestrationService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -54,10 +57,18 @@ public void updateVersion( Project project = projectAccessService.requireVersionPermission(projectId, user, "VERSION_EDIT", "You do not have permission to update this version."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(projectId, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new VersionNotFoundException("We couldn't find that project version.")); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var originalContext = mapper.valueToTree(java.util.Arrays.asList(version.getGameVersions(), version.getDependencies())); + String originalFingerprint = net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version); + var previousScan = version.getScanResult(); + String oldCachedArchive = null; + boolean childIdsChanged = false; if (gameVersions != null) { versionMutationOrchestrationService.validateGameVersions(gameVersions); version.setGameVersions(gameVersions); @@ -79,11 +90,16 @@ public void updateVersion( catch (java.io.IOException ex) { throw new net.modtale.exception.InvalidVersionRequestException("This mod has attached configs. Upload a new version to change its config ownership."); } } if (modpack) { - versionMutationOrchestrationService.invalidateCachedModpackArtifact(version, resolvedProjectDependencies); + if (!java.util.Objects.equals(mapper.valueToTree(version.getDependencies()), mapper.valueToTree(resolvedProjectDependencies)) + && version.getFileUrl() != null && version.getFileUrl().endsWith(".zip")) { + oldCachedArchive = version.getFileUrl(); + version.setFileUrl(null); + } } version.setDependencies(resolvedProjectDependencies); if (modpack && project.getVersions().get(0).getId().equals(versionId)) { project.setChildProjectIds(resolvedDependencies.simpleProjectIds()); + childIdsChanged = true; } } if (incompatibleProjectIds != null) { @@ -92,7 +108,27 @@ public void updateVersion( )); } - projectRepository.save(project); + String updatedFingerprint = net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version); + boolean contextChanged = originalFingerprint != null && updatedFingerprint != null + ? !originalFingerprint.equals(updatedFingerprint) : !originalContext.equals(mapper.valueToTree(java.util.Arrays.asList(version.getGameVersions(), version.getDependencies()))); + if (contextChanged && retainedMutations!=null) { + var outcome=retainedMutations.editVersion(snapshot.raw(),reviewPersistence.versionEditProposal(snapshot,versionId,childIdsChanged)); + if(!"APPLIED".equals(outcome.state()))throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(project); + return; + } + if(contextChanged && (version.getRetainedRemoteReview()!=null || version.getVersionMutation()!=null || version.getReviewReplacement()!=null + || version.getReviewIsolation()!=null || version.getReplacementSecurityHold()!=null || previousScan!=null && previousScan.getRemoteReview()!=null)) + throw new net.modtale.exception.InvalidVersionRequestException("Retained review history must be available before changing this version's runtime or dependencies."); + if (contextChanged) { + version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + version.setScheduledPublishDate(null); + version.setScanResult(null); + } + boolean queued = contextChanged && versionMutationOrchestrationService.prepareContextChangeScan(project, version, previousScan); + if (!reviewPersistence.applyVersionEdit(snapshot, versionId, contextChanged, childIdsChanged)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + if (queued) versionMutationOrchestrationService.enqueueContextChangeScan(project, version); + if (oldCachedArchive != null) versionMutationOrchestrationService.deleteCachedArtifact(oldCachedArchive); } } diff --git a/backend/src/main/java/net/modtale/service/security/access/AccessControlService.java b/backend/src/main/java/net/modtale/service/security/access/AccessControlService.java index 13747f715..4b7b27cb6 100644 --- a/backend/src/main/java/net/modtale/service/security/access/AccessControlService.java +++ b/backend/src/main/java/net/modtale/service/security/access/AccessControlService.java @@ -210,6 +210,11 @@ public boolean hasProjectPermission(Project project, User user, ApiKey.ApiPermis return hasProjectPermission(project, user, perm, findAuthorForPermission(project != null ? project.getAuthorId() : null)); } + public boolean hasCurrentProjectMembershipPermission(Project project, User user, ApiKey.ApiPermission permission) { + return permission != null && hasProjectPermission(project,user,permission, + findAuthorForPermission(project != null ? project.getAuthorId() : null)); + } + private boolean hasProjectPermission(Project project, User user, ApiKey.ApiPermission perm, User authorUser) { if (project == null || user == null) return false; if (project.getAuthorId() != null && project.getAuthorId().equals(user.getId())) return true; diff --git a/backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java b/backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java new file mode 100644 index 000000000..3671feba5 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java @@ -0,0 +1,98 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ArtifactClearancePolicy; +import net.modtale.service.security.scan.ArtifactReviewContext; + +/** Evaluates recorded reasoning; no assessment here grants whole-artifact clearance. */ +final class FindingDecisionValidity { + enum State { + APPLICABLE, REVIEW_REQUIRED, REVOKED, SUPERSEDED, EXPIRED, INVALID_RECORD, + INCOMPLETE_EVIDENCE, ARTIFACT_CHANGED, CONTEXT_CHANGED, POLICY_CHANGED, + POLICY_UNAVAILABLE, FINDING_CHANGED, ADVERSE_EVIDENCE, UNSUPPORTED_SCOPE, REVOCATION + } + record Assessment(State state, String explanation) {} + + Map assess(String projectId, ProjectVersion version, List events, + String currentPolicy, long now) { + var results = new LinkedHashMap(); + var revoked = new HashSet(); + var superseded = new HashSet(); + var scan = version == null ? null : version.getScanResult(); + var evidence = scan == null ? null : scan.getSecurityEvidence(); + boolean complete = ArtifactClearancePolicy.complete(scan); + var identities = IssueEvidenceIdentity.from(scan); + var occurrences = new HashMap(); + if (scan != null && scan.getIssues() != null) for (var issue : scan.getIssues()) { + String identity = identities.identify(issue); + if (identity != null) occurrences.put(identity, issue); + } + String context = ArtifactReviewContext.fingerprint(version); + // The caller supplies the complete, linked newest-first chain. Revoked superseding decisions + // do not resurrect earlier conclusions: a fresh explicit decision is required. + for (var event : events) { + if (event.revokedDecisionId() != null) revoked.add(event.revokedDecisionId()); + if (event.supersedesDecisionId() != null) superseded.add(event.supersedesDecisionId()); + } + for (var event : events) { + State state; + if (!validRecord(event, projectId, version, now)) state = State.INVALID_RECORD; + else if (event.disposition() == FindingReviewService.Disposition.REVOKE) state = State.REVOCATION; + else if (revoked.contains(event.id())) state = State.REVOKED; + else if (superseded.contains(event.id())) state = State.SUPERSEDED; + else if (event.disposition() == FindingReviewService.Disposition.REQUIRE_REVIEW) state = State.REVIEW_REQUIRED; + else if (event.expiresAt() <= now) state = State.EXPIRED; + else if (!"WHOLE_ARTIFACT".equals(event.scope())) state = State.UNSUPPORTED_SCOPE; + else if (!complete) state = State.INCOMPLETE_EVIDENCE; + else if ("BLOCK".equals(scan.getVerdict()) || scan.getStatus() == ScanStatus.INFECTED + || "NEW_SECURITY_EVIDENCE".equals(evidence.reviewState())) state = State.ADVERSE_EVIDENCE; + else if (!Objects.equals(version.getHash(), evidence.artifactSha256()) + || !event.contentSha256().equals(evidence.contentSha256())) state = State.ARTIFACT_CHANGED; + else if (context == null || !context.equals(scan.getReviewedContextSha256()) + || !context.equals(event.contextSha256())) state = State.CONTEXT_CHANGED; + else if (currentPolicy == null) state = State.POLICY_UNAVAILABLE; + else if (!currentPolicy.equals(evidence.policyVersion()) || !currentPolicy.equals(event.policyVersion())) state = State.POLICY_CHANGED; + else if (!sameFinding(event.finding(), occurrences.get(event.finding().identity()))) state = State.FINDING_CHANGED; + else state = State.APPLICABLE; + results.put(event.id(), new Assessment(state, explanation(state))); + } + return Collections.unmodifiableMap(results); + } + private boolean sameFinding(FindingReviewService.Finding recorded, ScanResult.ScanIssue current) { + return current != null && Objects.equals(recorded.path(), current.getFilePath()) + && Objects.equals(recorded.type(), current.getType()) && Objects.equals(recorded.description(), current.getDescription()) + && recorded.lineStart() == current.getLineStart() && recorded.lineEnd() == current.getLineEnd(); + } + boolean validRecord(FindingReviewService.Event event, String projectId, ProjectVersion version, long now) { + if (version == null || !Objects.equals(projectId, event.projectId()) || !Objects.equals(version.getId(), event.versionId()) + || event.disposition() == null || event.actorId() == null || event.actorId().isBlank() || event.actorId().length() > 256 + || event.rationale() == null || event.rationale().strip().length() < 10 || event.rationale().length() > 4000 || event.createdAt() <= 0 + || event.createdAt() > now || !SecurityManifest.digest(event.artifactSha256()) + || !SecurityManifest.digest(event.contentSha256()) || !SecurityManifest.digest(event.contextSha256()) + || event.policyVersion() == null || !event.policyVersion().matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || event.finding() == null || event.finding().identity() == null + || !event.finding().identity().matches("ie1:[0-9a-f]{64}")) return false; + return event.disposition() != FindingReviewService.Disposition.ACCEPT + || event.expiresAt() > event.createdAt() && event.expiresAt() - event.createdAt() <= 30L * 86400000; + } + private String explanation(State state) { + return switch (state) { + case APPLICABLE -> "The recorded finding, complete artifact contents, context and current policy still match. This acceptance covers the finding, not publication."; + case REVIEW_REQUIRED -> "A reviewer requested further investigation. This requirement does not expire automatically."; + case REVOKED -> "A later decision revoked this conclusion. It cannot be reused."; + case SUPERSEDED -> "A later conclusion replaced this decision. Revoking its replacement does not restore it."; + case EXPIRED -> "The acceptance has expired; inspect the current evidence again."; + case INVALID_RECORD -> "The decision lacks valid provenance or time bounds."; + case INCOMPLETE_EVIDENCE -> "Current complete, verified evidence is unavailable. Earlier reasoning cannot fill this gap."; + case ARTIFACT_CHANGED -> "The artifact identity or contents changed. This decision covers the whole artifact, including callers and resources."; + case CONTEXT_CHANGED -> "The runtime, dependencies, supplemental content or reviewed context no longer matches."; + case POLICY_CHANGED -> "The scanner policy changed; the earlier conclusion needs evaluation under the current policy."; + case POLICY_UNAVAILABLE -> "The current scanner policy could not be verified."; + case FINDING_CHANGED -> "The exact finding is absent or changed in the current scan."; + case ADVERSE_EVIDENCE -> "The current scan contains adverse evidence that prevents retaining this acceptance."; + case UNSUPPORTED_SCOPE -> "This decision scope is not supported by the current evaluator."; + case REVOCATION -> "This event records a revocation and grants no acceptance."; + }; + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java new file mode 100644 index 000000000..8f403ed61 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java @@ -0,0 +1,70 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.ProjectVersion; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** Reads only the immutable history reachable from the version's authoritative head. */ +public final class FindingReviewHistory { + public static final int MAX_EVENTS = 1000; + private FindingReviewHistory() {} + + public static List load(MongoTemplate mongo, String projectId, String versionId, String head) { + var events = new ArrayList(); + var visited = new HashSet(); + Integer expected = null; + for (String id = head; id != null;) { + if (!visited.add(id) || events.size() >= MAX_EVENTS) throw unavailable(); + var event = mongo.findById(id, FindingReviewService.Event.class, FindingReviewService.COLLECTION); + if (event == null || !id.equals(event.id()) || !projectId.equals(event.projectId()) + || !versionId.equals(event.versionId()) || event.sequence() <= 0 || event.sequence() > MAX_EVENTS + || expected != null && event.sequence() != expected) throw unavailable(); + expected = event.sequence() - 1; + events.add(event); id = event.previousId(); + } + if (expected != null && expected != 0) throw unavailable(); + var older = new HashMap(); + for (int i = events.size() - 1; i >= 0; i--) { + var event = events.get(i); + if (event.disposition() == null) throw unavailable(); + if (event.disposition() == FindingReviewService.Disposition.REVOKE) { + var target = older.get(event.revokedDecisionId()); + if (target == null || target.disposition() == FindingReviewService.Disposition.REVOKE + || event.supersedesDecisionId() != null) throw unavailable(); + } else { + if (event.revokedDecisionId() != null) throw unavailable(); + if (event.supersedesDecisionId() != null) { + var target = older.get(event.supersedesDecisionId()); + if (target == null || target.disposition() == FindingReviewService.Disposition.REVOKE + || event.finding() == null || target.finding() == null + || !Objects.equals(event.finding().identity(), target.finding().identity()) + || !Objects.equals(event.contentSha256(), target.contentSha256()) + || !Objects.equals(event.contextSha256(), target.contextSha256())) throw unavailable(); + } + } + older.put(event.id(), event); + } + return List.copyOf(events); + } + + /** This gate removes no findings and grants no automatic clearance. The caller must CAS the head. */ + public static void requireManualApproval(MongoTemplate mongo, String projectId, ProjectVersion version) { + if(version.getScanResult()!=null && "MUTATION_HELD".equals(version.getScanResult().getScanState()))throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.CONFLICT,"The changed version is awaiting retained review admission."); + if(version.getReplacementSecurityHold()!=null)throw new ResponseStatusException(HttpStatus.CONFLICT, + "Resolve the retained security block before approving this version."); + var events = load(mongo, projectId, version.getId(), version.getFindingReviewHead()); + var assessments = new FindingDecisionValidity().assess(projectId, version, events, null, System.currentTimeMillis()); + if (assessments.values().stream().anyMatch(value -> value.state() == FindingDecisionValidity.State.INVALID_RECORD)) + throw unavailable(); + if (assessments.values().stream().anyMatch(value -> value.state() == FindingDecisionValidity.State.REVIEW_REQUIRED)) + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Resolve or explicitly revoke the outstanding finding-review requirements before approving this version."); + } + + private static ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.CONFLICT, "Decision history is incomplete or inconsistent"); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java new file mode 100644 index 000000000..265cc5482 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java @@ -0,0 +1,147 @@ +package net.modtale.service.security.issue; + +import java.time.Instant; +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.VersionReviewPersistence; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.ArtifactReviewContext; +import net.modtale.service.security.scan.ArtifactClearancePolicy; +import net.modtale.service.security.scan.WardenClientService; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class FindingReviewService { + public static final String COLLECTION = "finding_review_events"; + private static final int MAX_EVENTS = FindingReviewHistory.MAX_EVENTS; + private final MongoTemplate mongo; + private final VersionReviewPersistence persistence; + private final ProjectService projects; + private final WardenClientService warden; + + public FindingReviewService(MongoTemplate mongo, VersionReviewPersistence persistence, ProjectService projects, WardenClientService warden) { + this.mongo = mongo; this.persistence = persistence; this.projects = projects; this.warden = warden; + } + + public enum Disposition { ACCEPT, REQUIRE_REVIEW, REVOKE } + public record Request(Integer issueIndex, Disposition disposition, String rationale) {} + public record Finding(String identity, String path, String type, String description, int lineStart, int lineEnd) {} + public record Event(@Id String id, String projectId, String versionId, String previousId, int sequence, + String actorId, long createdAt, long expiresAt, Disposition disposition, String rationale, + String scope, String artifactSha256, String contentSha256, String policyVersion, + String contextSha256, Finding finding, String revokedDecisionId, String supersedesDecisionId) {} + public record DecisionAssessment(String state, String explanation) {} + public record History(List events, Integer nextOffset, Map assessments, long assessedAt) {} + + public Event record(String projectId, String versionId, String token, String actorId, Request request) { + if (request == null || request.disposition() == null || request.disposition() == Disposition.REVOKE) + throw invalid("Choose an acceptance or a requirement for further review"); + validateText(actorId, request.rationale()); + var snapshot = persistence.capture(projectId, versionId, token); + var version = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + var scan = version.getScanResult(); + if (scan == null || scan.getIssues() == null || request.issueIndex() == null || request.issueIndex() < 0 || request.issueIndex() >= scan.getIssues().size()) + throw invalid("Select a finding from the inspected scan"); + var issue = scan.getIssues().get(request.issueIndex()); + var evidence = scan.getSecurityEvidence(); + String context = ArtifactReviewContext.fingerprint(version); + String identity = IssueEvidenceIdentity.from(scan).identify(issue); + if (identity == null || evidence == null || !Objects.equals(version.getHash(), evidence.artifactSha256()) + || context == null || !context.equals(scan.getReviewedContextSha256())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Complete artifact and context evidence is required to record this decision"); + if (issue.getDescription() != null && issue.getDescription().length() > 16000) + throw invalid("The finding exceeds the supported decision record size"); + var previous = head(projectId, versionId, version.getFindingReviewHead()); + String supersedes = chain(projectId, versionId, previous).stream() + .filter(event -> event.disposition() != Disposition.REVOKE && event.finding() != null + && identity.equals(event.finding().identity()) + && evidence.contentSha256().equals(event.contentSha256()) && context.equals(event.contextSha256())) + .map(Event::id).findFirst().orElse(null); + long now = Instant.now().toEpochMilli(); + var event = new Event(UUID.randomUUID().toString(), projectId, versionId, version.getFindingReviewHead(), + nextSequence(previous), actorId, now, request.disposition() == Disposition.ACCEPT ? now + 30L * 86_400_000 : 0, + request.disposition(), request.rationale().strip(), + "WHOLE_ARTIFACT", evidence.artifactSha256(), evidence.contentSha256(), evidence.policyVersion(), context, + new Finding(identity, issue.getFilePath(), issue.getType(), issue.getDescription(), issue.getLineStart(), issue.getLineEnd()), null, supersedes); + return persist(snapshot, event); + } + + public Event revoke(String projectId, String versionId, String token, String actorId, String decisionId, String rationale) { + validateText(actorId, rationale); + var snapshot = persistence.capture(projectId, versionId, token); + var version = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + var previous = head(projectId, versionId, version.getFindingReviewHead()); + Event target = null; + for (Event event : chain(projectId, versionId, previous)) { + if (Objects.equals(event.revokedDecisionId(), decisionId)) throw invalid("This decision was already revoked"); + if (event.id().equals(decisionId)) { target = event; break; } + } + if (target == null || target.disposition() == Disposition.REVOKE) throw invalid("Select an active history decision to revoke"); + var event = new Event(UUID.randomUUID().toString(), projectId, versionId, version.getFindingReviewHead(), + nextSequence(previous), actorId, Instant.now().toEpochMilli(), target.expiresAt(), Disposition.REVOKE, + rationale.strip(), target.scope(), target.artifactSha256(), target.contentSha256(), target.policyVersion(), + target.contextSha256(), target.finding(), target.id(), null); + return persist(snapshot, event); + } + + public History history(String projectId, String versionId, String token, int offset) { + if (offset < 0 || offset >= MAX_EVENTS) throw invalid("Invalid history offset"); + var snapshot = persistence.capture(projectId, versionId, token); + var version = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + var events = chain(projectId, versionId, head(projectId, versionId, version.getFindingReviewHead())); + int end = Math.min(events.size(), offset + 50); + var page = List.copyOf(events.subList(Math.min(offset, events.size()), end)); + String policy = ArtifactClearancePolicy.complete(version.getScanResult()) && !events.isEmpty() + ? warden.currentPolicyVersion() : null; + long assessedAt = Instant.now().toEpochMilli(); + var validity = new FindingDecisionValidity().assess(projectId, version, events, policy, assessedAt); + var assessments = new LinkedHashMap(); + for (var event : events) { + var assessment = validity.get(event.id()); + assessments.put(event.id(), new DecisionAssessment(assessment.state().name(), assessment.explanation())); + } + // Policy lookup and history reads must not conceal a concurrent change to this version. + persistence.capture(projectId, versionId, token); + return new History(page, end < events.size() ? end : null, Map.copyOf(assessments), assessedAt); + } + + private Event persist(VersionReviewPersistence.Snapshot snapshot, Event event) { + // Only a successfully linked event is active; interrupted or losing inserts are unreachable history. + mongo.insert(event, COLLECTION); + if (!persistence.appendFindingReview(snapshot, event.id())) throw VersionReviewPersistence.conflict(); + var project = projects.getRawProjectById(event.projectId()); + if (project != null) projects.evictProjectCache(project); + return event; + } + private Event head(String projectId, String versionId, String id) { + if (id == null) return null; + var event = mongo.findById(id, Event.class, COLLECTION); + if (event == null || !projectId.equals(event.projectId()) || !versionId.equals(event.versionId()) + || event.sequence() <= 0 || event.sequence() > MAX_EVENTS) throw unavailable(); + return event; + } + private List chain(String projectId, String versionId, Event head) { + return FindingReviewHistory.load(mongo, projectId, versionId, head == null ? null : head.id()); + } + + private int nextSequence(Event previous) { + if (previous != null && previous.sequence() >= MAX_EVENTS) + throw new ResponseStatusException(HttpStatus.CONFLICT, "This version reached the decision history limit"); + return previous == null ? 1 : previous.sequence() + 1; + } + private static void validateText(String actorId, String rationale) { + if (actorId == null || actorId.isBlank() || actorId.length() > 256) throw invalid("A reviewer identity is required"); + if (rationale == null || rationale.strip().length() < 10 || rationale.length() > 4000) + throw invalid("Explain the decision in 10 to 4000 characters"); + } + private static ResponseStatusException invalid(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + private static ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.CONFLICT, "Decision history is incomplete or inconsistent"); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java b/backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java new file mode 100644 index 000000000..24590ea62 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java @@ -0,0 +1,68 @@ +package net.modtale.service.security.issue; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import net.modtale.model.project.ScanResult; +import net.modtale.service.security.scan.ArtifactClearancePolicy; + +/** Exact local evidence comparison only; it does not establish cross-file safety or clearance. */ +final class IssueEvidenceIdentity { + private final String policy; + private final Map entries; + + private IssueEvidenceIdentity(String policy, Map entries) { + this.policy = policy; + this.entries = entries; + } + + static IssueEvidenceIdentity from(ScanResult scan) { + if (!ArtifactClearancePolicy.complete(scan)) return new IssueEvidenceIdentity(null, Map.of()); + return new IssueEvidenceIdentity(scan.getSecurityEvidence().policyVersion(), + Map.copyOf(scan.getSecurityEvidence().entryHashes())); + } + + String identify(ScanResult.ScanIssue issue) { return identify(issue, false); } + String reasoningIdentity(ScanResult.ScanIssue issue) { return identify(issue, true); } + private String identify(ScanResult.ScanIssue issue, boolean reasoningOnly) { + if (policy == null || issue == null || issue.getFilePath() == null) return null; + String fileHash = entries.get(issue.getFilePath()); + if (fileHash == null) return null; + try { + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + for (String value : new String[]{reasoningOnly ? "finding-reasoning-evidence-v1" : "finding-evidence-v1", policy, issue.getFilePath(), fileHash, + issue.getType(), issue.getCategory(), issue.getDescription(), issue.getSeverity(), + Integer.toString(issue.getLineStart()), Integer.toString(issue.getLineEnd())}) { + append(hash, value); + } + if (!reasoningOnly) { + append(hash, Integer.toString(issue.getScoreImpact())); + append(hash, Integer.toString(issue.getConfidence())); + } + append(hash, issue.getEvidenceLevel()); + append(hash, issue.getReviewCadence()); + if (!reasoningOnly) { + append(hash, issue.getReviewPriority()); + append(hash, Boolean.toString(issue.isNoiseSuppressed())); + } + hash.update(ByteBuffer.allocate(4).putInt(issue.getTactics().size()).array()); + for (String tactic : issue.getTactics()) append(hash, tactic); + return (reasoningOnly ? "re1:" : "ie1:") + HexFormat.of().formatHex(hash.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static void append(MessageDigest hash, String value) { + hash.update(ByteBuffer.allocate(4).putInt(value == null ? -1 : value.length()).array()); + if (value == null) return; + // Preserve exact Java strings, including unpaired surrogates, without replacement encoding. + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + hash.update((byte) (character >>> 8)); + hash.update((byte) character); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java b/backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java new file mode 100644 index 000000000..2db086845 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java @@ -0,0 +1,81 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +/** Read-only historical context. Matching reasoning never resolves a current finding. */ +@Service +public class PriorFindingReasoningService { + private final ProjectService projects; + private final MongoTemplate mongo; + public PriorFindingReasoningService(ProjectService projects, MongoTemplate mongo) { this.projects=projects;this.mongo=mongo; } + public record Reasoning(String reviewToken, String sourceVersionId, String sourceVersion, long assessedAt, + List reviewReasons, List decisions, int omitted) {} + public Reasoning read(String projectId,String targetId,String sourceId,int issueIndex,String token) { + var project=projects.getRawProjectById(projectId); + if(project==null || project.getVersions()==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project,token); + var target=version(project,targetId);var source=version(project,sourceId); + if(Objects.equals(sourceId,targetId)) throw unavailable(); + var scan=target.getScanResult(); + if(scan==null || scan.getIssues()==null || issueIndex<0 || issueIndex>=scan.getIssues().size()) throw unavailable(); + var issue=scan.getIssues().get(issueIndex); + String identity=IssueEvidenceIdentity.from(scan).identify(issue); + String context=ArtifactReviewContext.fingerprint(target); + if(identity==null || !Objects.equals(target.getHash(),scan.getSecurityEvidence().artifactSha256()) + || context==null || !context.equals(scan.getReviewedContextSha256())) throw unavailable(); + if(source.getFindingReviewHead()==null || ArtifactReviewLineage.extend(project,source)==null) throw unavailable(); + var approved=source.getApprovedSecurityEvidence(); + var events=FindingReviewHistory.load(mongo,projectId,sourceId,source.getFindingReviewHead()); + long now=System.currentTimeMillis(); + var removed=new HashSet(); + for(var event:events) { + if(!new FindingDecisionValidity().validRecord(event,projectId,source,now)) throw unavailable(); + if(event.revokedDecisionId()!=null) removed.add(event.revokedDecisionId()); + if(event.supersedesDecisionId()!=null) removed.add(event.supersedesDecisionId()); + } + if(events.stream().anyMatch(e->e.disposition()==FindingReviewService.Disposition.REQUIRE_REVIEW && !removed.contains(e.id()))) throw unavailable(); + String reasoningIdentity = IssueEvidenceIdentity.from(scan).reasoningIdentity(issue); + var approvedIdentities = new HashSet(); + if (source.getApprovedIssueBaselines() != null) for (var baseline : source.getApprovedIssueBaselines()) { + if (baseline == null || baseline.getEvidenceIdentity() == null + || !baseline.getEvidenceIdentity().matches("ie1:[0-9a-f]{64}")) continue; + if (identity.equals(baseline.getEvidenceIdentity()) || reasoningIdentity != null + && reasoningIdentity.equals(baseline.getReasoningEvidenceIdentity())) approvedIdentities.add(baseline.getEvidenceIdentity()); + } + var matched=events.stream().filter(e->approvedIdentities.contains(e.finding().identity()) && e.disposition()==FindingReviewService.Disposition.ACCEPT + && !removed.contains(e.id()) && "WHOLE_ARTIFACT".equals(e.scope()) + && Objects.equals(issue.getFilePath(),e.finding().path()) + && Objects.equals(issue.getType(),e.finding().type()) && Objects.equals(issue.getDescription(),e.finding().description()) + && issue.getLineStart()==e.finding().lineStart() && issue.getLineEnd()==e.finding().lineEnd() + && approved.contentSha256().equals(e.contentSha256()) && approved.policyVersion().equals(e.policyVersion()) + && source.getApprovedSecurityContextSha256().equals(e.contextSha256()) + && e.createdAt()<=source.getSecurityApprovedAt()).toList(); + var reasons=new ArrayList(); + if (matched.stream().anyMatch(e -> !identity.equals(e.finding().identity()))) reasons.add("Risk scoring changed; this earlier explanation does not establish current acceptance."); + if(!approved.contentSha256().equals(scan.getSecurityEvidence().contentSha256())) reasons.add("Artifact contents changed; callers and resources need current review."); + if(!source.getApprovedSecurityContextSha256().equals(context)) reasons.add("Runtime, dependency or manifest context changed."); + if("BLOCK".equals(scan.getVerdict()) || scan.getStatus()==ScanStatus.INFECTED + || "NEW_SECURITY_EVIDENCE".equals(scan.getSecurityEvidence().reviewState())) reasons.add("Current adverse evidence prevents retaining an acceptance."); + if(matched.stream().anyMatch(e->e.expiresAt()<=now)) reasons.add("Some earlier acceptances have expired."); + reasons.add("Historical reasoning only. Current scope, scanner policy and publication requirements still need evaluation."); + var current=projects.getRawProjectById(projectId); + if(current==null) throw ProjectReviewSnapshot.conflict(); + ProjectReviewSnapshot.requireCurrent(current,token); + return new Reasoning(token,sourceId,source.getVersionNumber(),now,List.copyOf(reasons),matched.stream().limit(20).toList(),Math.max(0,matched.size()-20)); + } + private ProjectVersion version(Project project,String id) { + var versions=project.getVersions().stream().filter(Objects::nonNull).filter(v->Objects.equals(id,v.getId())).toList(); + if(id==null || versions.size()!=1) throw unavailable();return versions.getFirst(); + } + private ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.CONFLICT,"Verified prior reasoning is unavailable for this evidence and source approval. Refresh the review or inspect source history."); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java index 546092797..2d7cd681c 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java @@ -56,8 +56,14 @@ public record IssueBaseline( String versionId, String versionNumber, long lastSeenTimestamp, - int seenCount + int seenCount, + String evidenceIdentity ) { + public IssueBaseline(String fingerprint, String looseFingerprint, String severity, int scoreImpact, + int confidence, String versionId, String versionNumber, long lastSeenTimestamp, int seenCount) { + this(fingerprint, looseFingerprint, severity, scoreImpact, confidence, versionId, versionNumber, + lastSeenTimestamp, seenCount, null); + } IssueBaseline mergeWith(IssueBaseline other) { if (other == null) return this; @@ -74,7 +80,8 @@ IssueBaseline mergeWith(IssueBaseline other) { thisLatest ? this.versionId : other.versionId, thisLatest ? this.versionNumber : other.versionNumber, Math.max(this.lastSeenTimestamp, other.lastSeenTimestamp), - this.seenCount + other.seenCount + this.seenCount + other.seenCount, + thisLatest ? this.evidenceIdentity : other.evidenceIdentity ); } diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java index 197eea401..00e68360e 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java @@ -21,13 +21,31 @@ public SecurityIssueApprovalService(SecurityIssueClassificationService securityI } public void markIssuesAcceptedForApprovedVersion(ProjectVersion version) { - if (version == null || version.getScanResult() == null) return; + if (version == null) return; + if(version.getScanResult()!=null && "MUTATION_HELD".equals(version.getScanResult().getScanState()))throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.CONFLICT,"The changed version is awaiting retained review admission."); + if(version.getReplacementSecurityHold()!=null)throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.CONFLICT,"Resolve the retained security block before approving this version."); + // Only the conditional manual approval writer may attest a reviewed finding-history head. + version.setApprovedFindingReviewHead(null); + if (version.getScanResult() == null) { + version.setSecurityApprovalProjectId(null); + version.setApprovedReviewOrigins(null); + version.setApprovedSecurityEvidence(null); + version.setApprovedSecurityContextSha256(null); + version.setSecurityApprovedAt(0); + version.setApprovedIssueBaselines(null); + return; + } ScanResult scanResult = version.getScanResult(); securityIssueClassificationService.normalizeScanResult(scanResult); List issues = scanResult.getIssues(); List approvedIssueBaselines = new ArrayList<>(); long approvedAt = approvedAt(version); + var identities = IssueEvidenceIdentity.from(java.util.Objects.equals(version.getHash(), + scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().artifactSha256()) + ? scanResult : null); for (ScanResult.ScanIssue issue : issues) { if (issue == null) continue; @@ -49,20 +67,41 @@ public void markIssuesAcceptedForApprovedVersion(ProjectVersion version) { issue.setBaselineScoreImpact(Math.max(0, issue.getScoreImpact())); } - approvedIssueBaselines.add(new ProjectVersion.ApprovedIssueBaseline( + var stored = new ProjectVersion.ApprovedIssueBaseline( fingerprint, looseFingerprint, severity, Math.max(0, issue.getScoreImpact()), Math.max(0, issue.getConfidence()), approvedAt - )); + ); + stored.setEvidenceIdentity(identities.identify(issue)); + stored.setReasoningEvidenceIdentity(identities.reasoningIdentity(issue)); + approvedIssueBaselines.add(stored); } scanResult.setKnownIssueCount(issues.size()); scanResult.setNewIssueCount(0); scanResult.setEscalatedIssueCount(0); + var evidence = scanResult.getSecurityEvidence(); + boolean reusableEvidence = net.modtale.service.security.scan.ArtifactClearancePolicy.complete(scanResult) + && java.util.Objects.equals(version.getHash(), evidence.artifactSha256()) + && java.util.Objects.equals(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version), + scanResult.getReviewedContextSha256()); + reusableEvidence = reusableEvidence && (scanResult.getReusedReviewVersion() == null + || net.modtale.service.security.scan.ArtifactReviewLineage.wellFormed(scanResult.getReusedReviewOrigins()) + && !scanResult.getReusedReviewOrigins().isEmpty()); + if (!reusableEvidence) version.setSecurityApprovalProjectId(null); + version.setApprovedReviewOrigins(!reusableEvidence ? null : scanResult.getReusedReviewVersion() == null + ? java.util.Map.of() : java.util.Map.copyOf(scanResult.getReusedReviewOrigins())); + version.setApprovedSecurityEvidence(!reusableEvidence ? null : new ScanResult.SecurityEvidence( + evidence.policyVersion(), evidence.artifactSha256(), evidence.contentSha256(), evidence.complete(), + evidence.clearanceGranted(), evidence.reviewState(), java.util.Map.of())); + version.setApprovedSecurityContextSha256(reusableEvidence ? scanResult.getReviewedContextSha256() : null); + version.setSecurityApprovedAt(!reusableEvidence ? 0 : scanResult.getReusedReviewApprovedAt() > 0 + ? scanResult.getReusedReviewApprovedAt() : Instant.now().toEpochMilli()); version.setApprovedIssueBaselines(approvedIssueBaselines); + if (scanResult.getRemoteReview()!=null) version.setRetainedRemoteReview(scanResult.getRemoteReview()); version.setScanResult(null); } @@ -74,10 +113,12 @@ public int pruneApprovedScanResults(Project project) { int pruned = 0; for (ProjectVersion version : project.getVersions()) { if (version == null + || version.getReplacementSecurityHold()!=null || version.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED || version.getScanResult() == null) { continue; } + version.setSecurityApprovalProjectId(project.getId()); markIssuesAcceptedForApprovedVersion(version); pruned++; } diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java index 5429bbb7a..80aee6688 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java @@ -33,7 +33,7 @@ SecurityIssueAnalysisService.BaselineIndex collectApprovedIssueBaselines( if (excludeVersionId != null && excludeVersionId.equals(version.getId())) { continue; } - if (version.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED) { + if (version.getReplacementSecurityHold()!=null || version.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED) { continue; } @@ -47,6 +47,9 @@ SecurityIssueAnalysisService.BaselineIndex collectApprovedIssueBaselines( continue; } + var identities = IssueEvidenceIdentity.from(java.util.Objects.equals(version.getHash(), + scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().artifactSha256()) + ? scanResult : null); for (ScanResult.ScanIssue issue : scanResult.getIssues()) { if (issue == null) { continue; @@ -64,7 +67,8 @@ SecurityIssueAnalysisService.BaselineIndex collectApprovedIssueBaselines( version.getId(), version.getVersionNumber(), approvedAt, - 1 + 1, + identities.identify(issue) ); exactBaselines.merge(fingerprint, candidate, SecurityIssueAnalysisService.IssueBaseline::mergeWith); @@ -103,7 +107,8 @@ private boolean collectStoredBaselines( version.getId(), version.getVersionNumber(), storedBaseline.getApprovedAt() > 0 ? storedBaseline.getApprovedAt() : approvedAt, - 1 + 1, + storedBaseline.getEvidenceIdentity() ); exactBaselines.merge(candidate.fingerprint(), candidate, SecurityIssueAnalysisService.IssueBaseline::mergeWith); diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java index 3b562a53c..ab67fa529 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java @@ -28,6 +28,7 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( ? new SecurityIssueAnalysisService.BaselineIndex(new HashMap<>(), new HashMap<>()) : approvedBaselines; + var identities = IssueEvidenceIdentity.from(scanResult); int known = 0; int fresh = 0; int escalated = 0; @@ -40,6 +41,7 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( continue; } + issue.setHistoricalFileEvidenceIdentical(false); String fingerprint = fingerprint(issue); String looseFingerprint = looseFingerprint(issue); issue.setFingerprint(fingerprint); @@ -63,6 +65,9 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( continue; } + String identity = identities.identify(issue); + issue.setHistoricalFileEvidenceIdentical(!looseMatch && identity != null + && identity.equals(baseline.evidenceIdentity())); issue.setKnownIssue(true); issue.setBaselineVersion(baseline.versionNumber()); issue.setBaselineScoreImpact(baseline.scoreImpact()); @@ -86,7 +91,7 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( || alwaysReview; issue.setEscalated(escalatedIssue); - issue.setResolved(!escalatedIssue); + issue.setResolved(false); if (escalatedIssue) { escalated++; @@ -131,8 +136,11 @@ void normalizeScanResult(ScanResult scanResult) { scanResult.setStatus(ScanStatus.INFECTED); } else if ("REVIEW".equalsIgnoreCase(scanResult.getVerdict())) { scanResult.setStatus(ScanStatus.SUSPICIOUS); - } else { + } else if ("AUTO_APPROVE".equalsIgnoreCase(scanResult.getVerdict())) { scanResult.setStatus(ScanStatus.CLEAN); + } else { + scanResult.setStatus(ScanStatus.SUSPICIOUS); + scanResult.setVerdict("REVIEW"); } } } @@ -183,46 +191,11 @@ private String normalizeToken(String value) { } private String normalizePathForFingerprint(String path) { - if (path == null || path.isBlank()) { - return "archive-root"; - } - - String normalized = path.replace('\\', '/').toLowerCase(Locale.ROOT); - String[] nestedParts = normalized.split("->"); - String tail = nestedParts[nestedParts.length - 1].trim(); - if (tail.isBlank()) { - tail = normalized; - } - - String[] segments = tail.split("/"); - StringBuilder canonical = new StringBuilder(); - int start = Math.max(0, segments.length - 4); - for (int i = start; i < segments.length; i++) { - String segment = segments[i].replaceAll("\\s+", ""); - if (segment.isBlank()) { - continue; - } - if (canonical.length() > 0) { - canonical.append('/'); - } - canonical.append(segment); - } - - String out = canonical.length() == 0 ? "archive-root" : canonical.toString(); - return out.replaceAll("[^a-z0-9./$:_-]+", ""); + return path == null || path.isBlank() ? "archive-root" : path; } private String normalizeDescription(String text) { - if (text == null || text.isBlank()) { - return "na"; - } - String normalized = text.toLowerCase(Locale.ROOT); - normalized = normalized.replaceAll("0x[0-9a-f]+", "0x#"); - normalized = normalized.replaceAll("\\b\\d{1,3}(?:\\.\\d{1,3}){3}\\b", "ip#"); - normalized = normalized.replaceAll("\\d+", "#"); - normalized = normalized.replaceAll("[^a-z0-9#:/._-]+", " "); - normalized = normalized.trim().replaceAll("\\s{2,}", " "); - return normalized; + return text == null || text.isBlank() ? "na" : text; } private String hash(String input) { @@ -233,9 +206,9 @@ private String hash(String input) { for (byte b : bytes) { out.append(String.format("%02x", b)); } - return out.substring(0, 24); + return out.toString(); } catch (NoSuchAlgorithmException e) { - return Integer.toHexString(input.hashCode()); + throw new IllegalStateException("Required fingerprint digest unavailable", e); } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java new file mode 100644 index 000000000..066a07080 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java @@ -0,0 +1,33 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import java.util.*; + +public final class ArtifactClearancePolicy { + private ArtifactClearancePolicy() {} + public static boolean complete(ScanResult result) { + if (result == null || !result.isArtifactVerified() || !"COMPLETED".equals(result.getScanState())) return false; + var summary = result.getSummary(); + var evidence = result.getSecurityEvidence(); + if (summary == null || summary.getRecoverableErrors() != 0 || summary.getOversizedEntriesSkipped() != 0 + || summary.getNestedArchiveReadFailures() != 0 || summary.getFilesScanned() <= 0 + || evidence == null || !evidence.complete() || (evidence.policyVersion() == null || !evidence.policyVersion().matches("warden-3\\.0\\.0:[0-9a-f]{64}")) + || !digest(evidence.artifactSha256()) || !digest(evidence.contentSha256()) + || !SecurityManifest.valid(evidence.entryHashes(), false)) return false; + return SecurityManifest.identity(evidence.entryHashes()).equals(evidence.contentSha256()); + } + + public static boolean cleared(ScanResult result) { + if (!complete(result) || "NEW_SECURITY_EVIDENCE".equals(result.getSecurityEvidence().reviewState()) || "BLOCK".equals(result.getVerdict()) || result.getStatus() == ScanStatus.INFECTED) return false; + return ("AUTO_APPROVE".equals(result.getVerdict()) && result.getStatus() == ScanStatus.CLEAN && result.getSecurityEvidence().clearanceGranted()) + || result.getReusedReviewVersion() != null && ArtifactReviewLineage.wellFormed(result.getReusedReviewOrigins()) + && !result.getReusedReviewOrigins().isEmpty(); + } + public static boolean boundToVersion(ProjectVersion version) { + if (version == null || version.getReplacementSecurityHold()!=null || version.getFindingReviewHead() != null || !cleared(version.getScanResult())) return false; + String context = ArtifactReviewContext.automaticallyReviewableFingerprint(version); + return context != null && context.equals(version.getScanResult().getReviewedContextSha256()) + && Objects.equals(version.getHash(), version.getScanResult().getSecurityEvidence().artifactSha256()); + } + private static boolean digest(String value) { return SecurityManifest.digest(value); } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java new file mode 100644 index 000000000..dc6d9a3f1 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java @@ -0,0 +1,53 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import net.modtale.model.project.ProjectVersion; +import java.security.*; +import java.util.*; + +public final class ArtifactReviewContext { + private static final ObjectMapper MAPPER = new ObjectMapper().configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + private ArtifactReviewContext() {} + public static String fingerprint(ProjectVersion version) { + if (version == null || hasSupplementalContent(version)) return null; + try { + List dependencies = new ArrayList<>(); + if (version.getDependencies() != null) for (var dependency : version.getDependencies()) { + if (dependency == null || dependency.isExternal() || dependency.getVersionNumber() == null + || dependency.getVersionNumber().isBlank() || dependency.getProjectId() == null) return null; + dependencies.add(MAPPER.writeValueAsString(Arrays.asList(dependency.getProjectId(), + dependency.getVersionNumber(), dependency.getDependencyType().name(), dependency.getSource().name()))); + } + Collections.sort(dependencies); + List games = new ArrayList<>(version.getGameVersions() == null ? List.of() : version.getGameVersions()); + if (games.stream().anyMatch(Objects::isNull)) return null; + Collections.sort(games); + byte[] canonical = MAPPER.writeValueAsBytes(Arrays.asList("artifact-context-1", games, dependencies, + version.getManifestId(), version.getManifestVersion())); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); + } catch (Exception invalidContext) { + return null; + } + } + public static String automaticallyReviewableFingerprint(ProjectVersion version) { + if (version == null || version.getDependencies() != null && !version.getDependencies().isEmpty()) return null; + return fingerprint(version); + } + public static org.springframework.data.mongodb.core.query.Criteria bindSnapshot( + org.springframework.data.mongodb.core.query.Criteria criteria, ProjectVersion version) { + return criteria.and("versionMutation").is(version.getVersionMutation()).and("retainedRemoteReview").is(version.getRetainedRemoteReview()) + .and("replacementSecurityHold").is(version.getReplacementSecurityHold()) + .and("reviewReplacement").is(version.getReviewReplacement()) + .and("findingReviewHead").is(version.getFindingReviewHead()).and("gameVersions").is(version.getGameVersions()) + .and("dependencies").is(version.getDependencies()) + .and("manifestId").is(version.getManifestId()) + .and("manifestVersion").is(version.getManifestVersion()) + .and("overrideFileUrl").is(version.getOverrideFileUrl()) + .and("modpackConfigs").is(version.getModpackConfigs()); + } + public static boolean hasSupplementalContent(ProjectVersion version) { + return version.getOverrideFileUrl() != null && !version.getOverrideFileUrl().isBlank() + || version.getModpackConfigs() != null && !version.getModpackConfigs().isEmpty(); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java new file mode 100644 index 000000000..646a7edfd --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java @@ -0,0 +1,128 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.security.*; +import java.util.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.query.*; + +/** Flattened approval ancestry; copied approvals never become independent trust roots. */ +public final class ArtifactReviewLineage { + private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private static final List FIELDS = List.of("_id", "hash", "reviewStatus", "replacementSecurityHold", "findingReviewHead", "approvedFindingReviewHead", + "securityApprovalProjectId", "approvedSecurityEvidence", "approvedSecurityContextSha256", "securityApprovedAt", "approvedReviewOrigins", + "gameVersions", "dependencies", "manifestId", "manifestVersion", "overrideFileUrl", "modpackConfigs"); + private ArtifactReviewLineage() {} + + public static boolean wellFormed(Map origins) { + return origins != null && origins.size() <= 32 && origins.entrySet().stream().allMatch(entry -> + validId(entry.getKey()) && SecurityManifest.digest(entry.getValue())); + } + private static boolean validId(String id) { return id != null && id.matches("[a-zA-Z0-9_-]{1,128}"); } + public static String stamp(ProjectVersion source) { + if (source == null || source.getReplacementSecurityHold()!=null || source.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED + || !Objects.equals(source.getFindingReviewHead(), source.getApprovedFindingReviewHead()) || !wellFormed(source.getApprovedReviewOrigins())) return null; + var evidence = source.getApprovedSecurityEvidence(); + String context = ArtifactReviewContext.fingerprint(source); + long now = System.currentTimeMillis(); + if (!validId(source.getId()) + || source.getSecurityApprovalProjectId() == null || source.getSecurityApprovalProjectId().isBlank() || evidence == null || !evidence.complete() || context == null + || !context.equals(source.getApprovedSecurityContextSha256()) + || !Objects.equals(source.getHash(), evidence.artifactSha256()) + || !SecurityManifest.digest(evidence.contentSha256()) || !SecurityManifest.digest(evidence.artifactSha256()) + || evidence.policyVersion() == null || !evidence.policyVersion().matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || source.getSecurityApprovedAt() <= 0 || source.getSecurityApprovedAt() > now + || now - source.getSecurityApprovedAt() > 30L * 86400000) return null; + try { + var fields = Arrays.asList("approval-origin-2", source.getFindingReviewHead(), source.getApprovedFindingReviewHead(), source.getSecurityApprovalProjectId(), source.getId(), source.getHash(), evidence.policyVersion(), + evidence.contentSha256(), evidence.reviewState(), evidence.clearanceGranted(), context, + source.getSecurityApprovedAt(), source.getApprovedReviewOrigins()); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(fields))); + } catch (Exception invalid) { return null; } + } + public static boolean valid(Project project, Map origins) { + if (!wellFormed(origins) || project == null || project.getVersions() == null) return false; + var versions = new HashMap(); + for (var version : project.getVersions()) { + if (version == null || version.getId() == null || versions.put(version.getId(), version) != null) return false; + } + for (var origin : origins.entrySet()) { + var source = versions.get(origin.getKey()); + if (source == null || !Objects.equals(project.getId(), source.getSecurityApprovalProjectId()) || !origin.getValue().equals(stamp(source))) return false; + // Every ancestor of every copied source must also appear in this flattened proof. + for (var ancestor : source.getApprovedReviewOrigins().entrySet()) + if (!ancestor.getValue().equals(origins.get(ancestor.getKey()))) return false; + } + return true; + } + public static Map extend(Project project, ProjectVersion source) { + if (project == null || source == null || !Objects.equals(project.getId(), source.getSecurityApprovalProjectId())) return null; + String stamp = stamp(source); + if (stamp == null || !valid(project, source.getApprovedReviewOrigins())) return null; + var origins = new TreeMap<>(source.getApprovedReviewOrigins()); + if (origins.putIfAbsent(source.getId(), stamp) != null || origins.size() > 32) return null; + return Map.copyOf(origins); + } + + public static void invalidate(ScanResult scan) { + scan.setReusedReviewVersion(null); scan.setReusedReviewApprovedAt(0); + if (!"BLOCK".equals(scan.getVerdict()) && scan.getStatus() != ScanStatus.INFECTED) { + scan.setVerdict("REVIEW"); scan.setStatus(ScanStatus.SUSPICIOUS); + } + if (scan.getIssues() != null) for (var issue : scan.getIssues()) if (issue != null) { + issue.setResolved(false); issue.setHistoricalFileEvidenceIdentical(false); + } + var notes = new ArrayList<>(scan.getReviewerNotes() == null ? List.of() : scan.getReviewerNotes()); + notes.add("The source approval changed or is unavailable. A current moderator review is required."); + scan.setReviewerNotes(notes); + } + /** Adds source checks to the same atomic write as publication; false never authorizes a write. */ + public static boolean bind(MongoTemplate mongo, String projectId, ScanResult scan, Query query) { + if (scan.getReusedReviewVersion() == null) return scan.getReusedReviewOrigins() == null || scan.getReusedReviewOrigins().isEmpty(); + var origins = scan.getReusedReviewOrigins(); + if (!wellFormed(origins) || origins.isEmpty()) return false; + var entity = mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var filter = new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id", projectId), entity); + var raw = mongo.getCollection(mongo.getCollectionName(Project.class)).find(filter).first(); + if (raw == null) return false; + var project = mongo.getConverter().read(Project.class, raw); + var evidence = scan.getSecurityEvidence(); + if (!valid(project, origins) || evidence == null || !SecurityManifest.digest(scan.getReviewedContextSha256())) return false; + for (var source : project.getVersions()) if (origins.containsKey(source.getId())) { + try { + net.modtale.service.security.issue.FindingReviewHistory.requireManualApproval(mongo, projectId, source); + } catch (org.springframework.web.server.ResponseStatusException invalidHistory) { + return false; + } + var prior = source.getApprovedSecurityEvidence(); + if (!Objects.equals(evidence.contentSha256(), prior.contentSha256()) + || !Objects.equals(evidence.policyVersion(), prior.policyVersion()) + || !Objects.equals(scan.getReviewedContextSha256(), source.getApprovedSecurityContextSha256()) + || scan.getReusedReviewApprovedAt() != source.getSecurityApprovedAt()) return false; + } + var checks = new ArrayList(); + for (var item : raw.getList("versions", Document.class)) { + if (!origins.containsKey(Objects.toString(item.get("_id"), null))) continue; + var terms = new ArrayList(); + for (String field : FIELDS) { + var actual = new Document("$ifNull", Arrays.asList("$$origin." + field, null)); + terms.add(new Document("$eq", Arrays.asList(actual, new Document("$literal", item.get(field))))); + } + var databaseNow = new Document("$toLong", "$$NOW"); + terms.add(new Document("$lte", List.of("$$origin.securityApprovedAt", databaseNow))); + terms.add(new Document("$gt", List.of("$$origin.securityApprovedAt", + new Document("$subtract", List.of(databaseNow, 30L * 86400000))))); + var matches = new Document("$filter", new Document("input", "$versions").append("as", "origin") + .append("cond", new Document("$and", terms))); + checks.add(new Document("$eq", List.of(new Document("$size", matches), 1))); + } + if (checks.size() != origins.size()) return false; + // Expressions inspect source versions without competing with the target's positional match. + query.addCriteria(Criteria.where("$expr").is(new Document("$and", checks))); + return true; + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java new file mode 100644 index 000000000..d2e8ead32 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java @@ -0,0 +1,55 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.*; + +@Service +public class ArtifactReviewReuseService { + private static final long MAX_AGE_MS = Duration.ofDays(30).toMillis(); + public void annotate(Project project, String currentVersionId, ScanResult result) { + result.setReusedReviewVersion(null); + result.setReusedReviewApprovedAt(0); + result.setReusedReviewOrigins(null); + var current = result.getSecurityEvidence(); + if (!ArtifactClearancePolicy.complete(result) || ArtifactClearancePolicy.cleared(result) || current == null || !current.complete() + || "NEW_SECURITY_EVIDENCE".equals(current.reviewState()) + || "BLOCK".equals(result.getVerdict()) || result.getStatus() == ScanStatus.INFECTED + || project == null || project.getVersions() == null) return; + ProjectVersion target = project.getVersions().stream().filter(Objects::nonNull) + .filter(version -> Objects.equals(currentVersionId, version.getId())).findFirst().orElse(null); + String context = ArtifactReviewContext.automaticallyReviewableFingerprint(target); + if (context == null || target.getReplacementSecurityHold()!=null || target.getFindingReviewHead() != null) return; + long now = System.currentTimeMillis(); + for (ProjectVersion version : project.getVersions()) { + if (version == null || Objects.equals(version.getId(), currentVersionId) + || version.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED + || version.getSecurityApprovedAt() <= 0 || version.getSecurityApprovedAt() > now + || now - version.getSecurityApprovedAt() > MAX_AGE_MS) continue; + var prior = version.getApprovedSecurityEvidence(); + if (prior == null || !prior.complete() || current.policyVersion() == null + || !current.policyVersion().equals(prior.policyVersion()) + || current.contentSha256() == null || !current.contentSha256().equals(prior.contentSha256()) + || current.entryHashes() == null || current.entryHashes().isEmpty() + || !context.equals(version.getApprovedSecurityContextSha256()) + || !context.equals(ArtifactReviewContext.fingerprint(version))) continue; + var origins = ArtifactReviewLineage.extend(project, version); + if (origins == null) continue; + result.setReusedReviewVersion(version.getVersionNumber()); + result.setReusedReviewOrigins(origins); + result.setReusedReviewApprovedAt(version.getSecurityApprovedAt()); + for (var issue : result.getIssues()) { + if (issue == null) continue; + issue.setKnownIssue(true); + issue.setEscalated(false); + issue.setResolved(true); + issue.setBaselineVersion(version.getVersionNumber()); + } + result.setKnownIssueCount(result.getIssues().size()); + result.setNewIssueCount(0); + result.setEscalatedIssueCount(0); + return; + } + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/BoundedReviewScheduler.java b/backend/src/main/java/net/modtale/service/security/scan/BoundedReviewScheduler.java new file mode 100644 index 000000000..8d037c459 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/BoundedReviewScheduler.java @@ -0,0 +1,110 @@ +package net.modtale.service.security.scan; + +import org.springframework.context.SmartLifecycle; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +public class BoundedReviewScheduler implements SmartLifecycle,AutoCloseable { + public record Page(List candidates,K next) {public Page{candidates=List.copyOf(candidates);}} + @FunctionalInterface public interface Discovery {Page page(K cursor,int limit);} + @FunctionalInterface public interface Step {String advance(C candidate,java.util.function.BooleanSupplier running);} + public record Settings(int workers,int pageSize,long pollMillis,long drainMillis) { + public Settings {if(workers<1 || workers>2 || pageSize<1 || pageSize>64 || pollMillis<100 || pollMillis>60000 || drainMillis<100 || drainMillis>30000)throw new IllegalArgumentException("Invalid remote scheduler limits");} + } + public record Status(String state,int active,int buffered,long pages,long processed,long failures,String lastOutcome) {} + private final Discovery discovery; + private final Step step; + private final String threadName; + private final Settings settings; + private final Object lifecycle=new Object(),queueLock=new Object(); + private final CountDownLatch stopping=new CountDownLatch(1); + private final ArrayDeque queue=new ArrayDeque<>(); + private final Set inFlight=new HashSet<>(); + private K cursor; + private final AtomicInteger active=new AtomicInteger(),buffered=new AtomicInteger(); + private final AtomicLong pages=new AtomicLong(),processed=new AtomicLong(),failures=new AtomicLong(); + private final Queue callbacks=new ConcurrentLinkedQueue<>(); + private final AtomicBoolean watching=new AtomicBoolean(); + private final Set threads=ConcurrentHashMap.newKeySet(); + private volatile String state="NEW",lastOutcome="NONE"; + private volatile ExecutorService executor; + public BoundedReviewScheduler(String threadName,Discovery discovery,Step step,Settings settings) { + this.threadName=Objects.requireNonNull(threadName);this.discovery=Objects.requireNonNull(discovery);this.step=Objects.requireNonNull(step);this.settings=Objects.requireNonNull(settings); + } + @Override public void start() { + synchronized(lifecycle) { + if("RUNNING".equals(state))return;if(!"NEW".equals(state))throw new IllegalStateException("Closed scheduler cannot restart"); + executor=Executors.newFixedThreadPool(settings.workers(),r->{var t=new Thread(r,threadName);t.setDaemon(true);return t;});state="RUNNING"; + try{for(int i=0;isettings.pageSize())throw new IllegalStateException("Oversized discovery page"); + queue.addAll(page.candidates());cursor=page.next();pages.incrementAndGet();buffered.set(queue.size()); + } + while(!queue.isEmpty()) {var candidate=queue.removeFirst();buffered.set(queue.size());if(inFlight.add(candidate))return candidate;} + return null; + } + } + private void loop() { + threads.add(Thread.currentThread()); + try { + while(stopping.getCount()!=0) { + long pause=settings.pollMillis();C candidate=null; + try { + candidate=next(); + if(candidate!=null) { + active.incrementAndGet();var outcome=step.advance(candidate, + ()->stopping.getCount()!=0 && !Thread.currentThread().isInterrupted()); + lastOutcome=outcome;processed.incrementAndGet(); + if(Set.of("RETRY","UNKNOWN").contains(outcome)){failures.incrementAndGet();pause=Math.max(5000,pause);} + } + } catch(RuntimeException failure) {if(stopping.getCount()==0)break;failures.incrementAndGet();lastOutcome="POLL_ERROR";pause=Math.max(5000,pause);} + finally {if(candidate!=null){active.decrementAndGet();synchronized(queueLock){inFlight.remove(candidate);}}} + if(stopping.await(pause,TimeUnit.MILLISECONDS))break; + } + } catch(InterruptedException interrupted) {Thread.currentThread().interrupt();if(stopping.getCount()!=0){failures.incrementAndGet();lastOutcome="LOOP_INTERRUPTED";requestStop();}} + catch(Error fatal){failures.incrementAndGet();lastOutcome="LOOP_FAILED";requestStop();throw fatal;} + finally {threads.remove(Thread.currentThread());} + } + private void requestStop() { + synchronized(lifecycle) {if(stopping.getCount()==0)return;state="STOPPING";stopping.countDown();if(executor!=null)executor.shutdownNow();} + } + private boolean stopped() { + var service=executor; + if(stopping.getCount()==0 && (service==null || service.isTerminated()) && active.get()==0) {state="STOPPED";return true;} + return false; + } + private boolean awaitStop(long millis)throws InterruptedException { + var service=executor;if(service!=null)service.awaitTermination(millis,TimeUnit.MILLISECONDS);return stopped(); + } + @Override public void stop() { + requestStop();if(threads.contains(Thread.currentThread()))return; + try {if(!awaitStop(settings.drainMillis()))state="DRAIN_TIMEOUT";}catch(InterruptedException interrupted){Thread.currentThread().interrupt();state="DRAIN_INTERRUPTED";} + } + @Override public void stop(Runnable callback) { + callbacks.add(Objects.requireNonNull(callback));requestStop();watch(); + } + private void watch() { + if(!watching.compareAndSet(false,true))return; + Thread.startVirtualThread(()->{ + try { + if(!awaitStop(settings.drainMillis()))state="DRAIN_TIMEOUT"; + while(!stopped())awaitStop(1000); + Runnable callback;while((callback=callbacks.poll())!=null)try{callback.run();}catch(RuntimeException failure){failures.incrementAndGet();} + } catch(InterruptedException interrupted){Thread.currentThread().interrupt();state="DRAIN_INTERRUPTED";} + finally {watching.set(false);if(!callbacks.isEmpty())watch();} + }); + } + public Status status(){stopped();return new Status(state,active.get(),buffered.get(),pages.get(),processed.get(),failures.get(),lastOutcome);} + @Override public boolean isRunning(){return !"NEW".equals(state) && !stopped();} + @Override public boolean isAutoStartup(){return true;} + @Override public int getPhase(){return Integer.MAX_VALUE;} + @Override public void close(){stop();if(!stopped())throw new IllegalStateException("Remote scheduler drain incomplete");} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/DependencyArtifactVerifier.java b/backend/src/main/java/net/modtale/service/security/scan/DependencyArtifactVerifier.java new file mode 100644 index 000000000..092de80a8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/DependencyArtifactVerifier.java @@ -0,0 +1,102 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.storage.StorageService; +import java.io.*; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; + +/** Bounded observations of stored bytes, never a review decision or publication capability. */ +public final class DependencyArtifactVerifier { + public enum State { MATCHED, MISMATCH, UNAVAILABLE, BYTE_LIMIT, TIME_LIMIT, BUSY, UNRESOLVED } + public record Artifact(String fileReference,String expectedSha256,String actualSha256,long bytes,State state) {} + public record Result(String inventoryIdentity,List artifacts,State state,long bytes) { + public Result {artifacts=List.copyOf(artifacts);} + public boolean matched(){return state==State.MATCHED;} + } + @FunctionalInterface interface Open {InputStream open(String reference)throws IOException;} + private static final Semaphore CAPACITY=new Semaphore(2); + private final Open open; + private final Semaphore capacity; + public DependencyArtifactVerifier(StorageService storage){this(storage::getStream,CAPACITY);} + DependencyArtifactVerifier(Open open,Semaphore capacity){this.open=Objects.requireNonNull(open);this.capacity=capacity;} + public Result verify(DependencyReviewGraph.Inventory inventory) { + return verify(inventory,128L*1024*1024,Duration.ofSeconds(15)); + } + Result verify(DependencyReviewGraph.Inventory inventory,long maxBytes,Duration timeout) { + Objects.requireNonNull(inventory); + long nanos=timeout.toNanos(); + if(maxBytes<1||maxBytes>128L*1024*1024||nanos<1||nanos>TimeUnit.SECONDS.toNanos(15))throw new IllegalArgumentException("Invalid byte inspection limits"); + if(!inventory.resolved()||inventory.nodes().isEmpty()||inventory.nodes().size()>64) + return new Result(inventory.identity(),List.of(),State.UNRESOLVED,0); + if(!capacity.tryAcquire())return new Result(inventory.identity(),List.of(),State.BUSY,0); + var future=new CompletableFuture(); + long started=System.nanoTime(); + Thread worker; + try { + worker=Thread.ofVirtual().name("dependency-byte-inspection").start(()->{ + try {future.complete(read(inventory,maxBytes,started,nanos));} + catch(Exception failure){future.complete(new Result(inventory.identity(),List.of(),State.UNAVAILABLE,0));} + finally {capacity.release();} + }); + } catch(RuntimeException failure){capacity.release();throw failure;} + try { + var result=future.get(Math.max(1,nanos-(System.nanoTime()-started)),TimeUnit.NANOSECONDS); + return expired(started,nanos)?new Result(inventory.identity(),List.of(),State.TIME_LIMIT,0):result; + } + catch(TimeoutException failure){worker.interrupt();return new Result(inventory.identity(),List.of(),State.TIME_LIMIT,0);} + catch(InterruptedException failure){worker.interrupt();Thread.currentThread().interrupt();return new Result(inventory.identity(),List.of(),State.TIME_LIMIT,0);} + catch(ExecutionException failure){return new Result(inventory.identity(),List.of(),State.UNAVAILABLE,0);} + // A stalled stream retains its capacity slot until it actually exits, even after caller timeout. + } + private Result read(DependencyReviewGraph.Inventory inventory,long maxBytes,long started,long nanos)throws Exception { + var artifacts=new ArrayList();var seen=new HashMap();long total=0; + for(var node:inventory.nodes()) { + if(expired(started,nanos))return new Result(inventory.identity(),artifacts,State.TIME_LIMIT,total); + var previous=seen.get(node.fileReference()); + if(previous!=null) { + if(!previous.expectedSha256().equals(node.artifactSha256())) { + artifacts.add(new Artifact(node.fileReference(),node.artifactSha256(),previous.actualSha256(),0,State.MISMATCH)); + return new Result(inventory.identity(),artifacts,State.MISMATCH,total); + } + continue; + } + if(total>=maxBytes)return new Result(inventory.identity(),artifacts,State.BYTE_LIMIT,total); + long bytes=0;String actual=null;State state; + InputStream stream=null; + try { + stream=open.open(node.fileReference()); + var digest=MessageDigest.getInstance("SHA-256");var buffer=new byte[64*1024]; + while(true) { + if(expired(started,nanos))throw new TimeLimit(); + int read=stream.read(buffer,0,(int)Math.min(buffer.length,maxBytes-total-bytes+1)); + if(expired(started,nanos))throw new TimeLimit(); + if(read<0)break; + if(read==0)throw new IOException("Stored artifact stream made no progress"); + bytes+=read; + if(bytes>maxBytes-total)throw new ByteLimit(); + digest.update(buffer,0,read); + } + actual=HexFormat.of().formatHex(digest.digest()); + state=actual.equals(node.artifactSha256())?State.MATCHED:State.MISMATCH; + } catch(ByteLimit failure){state=State.BYTE_LIMIT;} + catch(TimeLimit failure){state=State.TIME_LIMIT;} + catch(Exception failure){state=State.UNAVAILABLE;} + finally { + if(stream!=null) { + // S3 close may drain the remaining object; abort instead to preserve transfer bounds. + if(stream instanceof software.amazon.awssdk.core.ResponseInputStream response)response.abort(); + else stream.close(); + } + } + total+=bytes;var artifact=new Artifact(node.fileReference(),node.artifactSha256(),actual,bytes,state); + artifacts.add(artifact);seen.put(node.fileReference(),artifact); + if(state!=State.MATCHED)return new Result(inventory.identity(),artifacts,state,total); + } + return new Result(inventory.identity(),artifacts,State.MATCHED,total); + } + private static boolean expired(long started,long nanos){return Thread.currentThread().isInterrupted()||System.nanoTime()-started>=nanos;} + private static class ByteLimit extends IOException {} + private static class TimeLimit extends IOException {} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/DependencyReviewGraph.java b/backend/src/main/java/net/modtale/service/security/scan/DependencyReviewGraph.java new file mode 100644 index 000000000..7d64b1a7e --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/DependencyReviewGraph.java @@ -0,0 +1,155 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.modtale.model.project.ProjectDependency; +import java.security.MessageDigest; +import java.util.*; + +/** Immutable declaration inventory. Resolution is not a security clearance or proof of current storage bytes. */ +public final class DependencyReviewGraph { + public record Reference(String projectId, String versionNumber) { + public Reference { text(projectId,128); text(versionNumber,128); } + } + public record Dependency(ProjectDependency.Source source, ProjectDependency.DependencyType type, Reference reference) { + public Dependency { Objects.requireNonNull(source); Objects.requireNonNull(type); Objects.requireNonNull(reference); } + } + public record RecordedScan(String artifactSha256, String contentSha256, String policyVersion) { + public RecordedScan { digest(artifactSha256); digest(contentSha256); text(policyVersion,256); } + } + public record Snapshot(String projectId, String versionId, String versionNumber, String fileReference, + String artifactSha256, String contextSha256, RecordedScan recordedScan, + boolean supplementalContent, List dependencies) { + public Snapshot { + text(projectId,128); text(versionId,128); text(versionNumber,128); text(fileReference,2048); + digest(artifactSha256); if(contextSha256!=null)digest(contextSha256); + dependencies=List.copyOf(dependencies); + if(dependencies.size()>256)throw new IllegalArgumentException("Dependency inventory exceeds limit"); + } + Reference reference(){return new Reference(projectId,versionNumber);} + Key key(){return new Key(projectId,versionId);} + } + public record Key(String projectId,String versionId) {} + public enum State { FOUND, MISSING, AMBIGUOUS, UNAVAILABLE } + public record Lookup(State state, Snapshot snapshot) { + public Lookup { + Objects.requireNonNull(state); + if((state==State.FOUND)!=(snapshot!=null))throw new IllegalArgumentException("Invalid dependency lookup"); + } + } + @FunctionalInterface public interface Source { Lookup read(Reference reference); } + public record Limits(int nodes,int edges,int depth,int reads) { + public Limits { + if(nodes<1||nodes>64||edges<1||edges>256||depth<0||depth>8||reads<1||reads>128) + throw new IllegalArgumentException("Invalid dependency graph limits"); + } + public static Limits defaults(){return new Limits(64,256,8,128);} + } + public enum Reason { MISSING, AMBIGUOUS, UNAVAILABLE, EXTERNAL, CYCLE, DEPTH_LIMIT, NODE_LIMIT, + EDGE_LIMIT, READ_LIMIT, CHANGED, INVALID_IDENTITY, UNRESOLVED_CONTEXT, SUPPLEMENTAL_CONTENT } + public record Gap(Key from,Reference reference,Reason reason) {} + public record Edge(Key from,int declaration,Dependency dependency,Key target) {} + public record Inventory(Key root,List nodes,List edges,List gaps,String identity,int reads) { + public Inventory {nodes=List.copyOf(nodes);edges=List.copyOf(edges);gaps=List.copyOf(gaps);} + public boolean resolved(){return gaps.isEmpty()&&identity!=null;} + } + private DependencyReviewGraph() {} + public static Inventory inspect(Snapshot root,Source source,Limits limits) { + Objects.requireNonNull(root);Objects.requireNonNull(source);Objects.requireNonNull(limits); + return new Walker(source,limits).inspect(root); + } + private static final class Walker { + final Source source;final Limits limits; + final Map nodes=new LinkedHashMap<>(); + final Map lookups=new LinkedHashMap<>(); + final List edges=new ArrayList<>();final List gaps=new ArrayList<>(); + int reads; + Walker(Source source,Limits limits){this.source=source;this.limits=limits;} + Inventory inspect(Snapshot root) { + lookups.put(root.reference(),new Lookup(State.FOUND,root)); + visit(root,0,new HashSet<>()); + checkSharedPathDepth(root.key()); + // Re-read every resolved pin, including the root. Callers must still bind this inventory at publication. + for(var entry:lookups.entrySet()) { + if(entry.getValue().state()!=State.FOUND)continue; + var fresh=read(entry.getKey(),root.key()); + if(fresh==null)break; + if(!entry.getValue().equals(fresh))gap(root.key(),entry.getKey(),Reason.CHANGED); + } + var ordered=nodes.values().stream().sorted(Comparator.comparing(Snapshot::projectId).thenComparing(Snapshot::versionId)).toList(); + String identity=null; + if(gaps.isEmpty()) { + try { + byte[] canonical=new ObjectMapper().writeValueAsBytes(List.of("dependency-inventory-1",root.key(),ordered,edges)); + identity=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); + }catch(Exception failure){throw new IllegalStateException("Cannot bind dependency inventory",failure);} + } + return new Inventory(root.key(),ordered,edges,gaps,identity,reads); + } + void checkSharedPathDepth(Key root) { + // A shared node may first be visited through a shorter path. Validate all retained paths, + // without expanding a diamond graph exponentially or duplicating its declarations. + Map depths=new HashMap<>();depths.put(root,0); + for(int round=0;round<=limits.depth();round++) { + Map next=new HashMap<>(depths); + boolean changed=false; + for(var edge:edges) { + Integer parent=depths.get(edge.from()); + if(parent==null||edge.target()==null)continue; + int depth=parent+1; + if(depth>limits.depth()) { + gap(edge.from(),edge.dependency().reference(),Reason.DEPTH_LIMIT);return; + } + if(depth>next.getOrDefault(edge.target(),-1)) { + next.put(edge.target(),depth);changed=true; + } + } + depths=next;if(!changed)return; + } + } + void visit(Snapshot node,int depth,Set path) { + if(path.contains(node.key())){gap(node.key(),node.reference(),Reason.CYCLE);return;} + var previous=nodes.get(node.key()); + if(previous!=null){if(!previous.equals(node))gap(node.key(),node.reference(),Reason.CHANGED);return;} + if(nodes.size()>=limits.nodes()){gap(node.key(),node.reference(),Reason.NODE_LIMIT);return;} + nodes.put(node.key(),node);path.add(node.key()); + if(node.contextSha256()==null)gap(node.key(),node.reference(),Reason.UNRESOLVED_CONTEXT); + if(node.supplementalContent())gap(node.key(),node.reference(),Reason.SUPPLEMENTAL_CONTENT); + if(node.recordedScan()!=null&&!node.artifactSha256().equals(node.recordedScan().artifactSha256())) + gap(node.key(),node.reference(),Reason.INVALID_IDENTITY); + for(int i=0;i=limits.edges()){gap(node.key(),dependency.reference(),Reason.EDGE_LIMIT);break;} + int edge=edges.size();edges.add(new Edge(node.key(),i,dependency,null)); + if(dependency.source()!=ProjectDependency.Source.MODTALE){gap(node.key(),dependency.reference(),Reason.EXTERNAL);continue;} + if(depth>=limits.depth()){gap(node.key(),dependency.reference(),Reason.DEPTH_LIMIT);continue;} + var lookup=lookups.get(dependency.reference()); + if(lookup==null) { + lookup=read(dependency.reference(),node.key()); + if(lookup==null)break; + lookups.put(dependency.reference(),lookup); + } + if(lookup.state()!=State.FOUND){gap(node.key(),dependency.reference(),Reason.valueOf(lookup.state().name()));continue;} + var child=lookup.snapshot(); + if(!child.projectId().equals(dependency.reference().projectId()) + || !child.versionNumber().equalsIgnoreCase(dependency.reference().versionNumber())) { + gap(node.key(),dependency.reference(),Reason.INVALID_IDENTITY);continue; + } + edges.set(edge,new Edge(node.key(),i,dependency,child.key())); + visit(child,depth+1,path); + } + path.remove(node.key()); + } + Lookup read(Reference reference,Key from) { + if(reads>=limits.reads()){gap(from,reference,Reason.READ_LIMIT);return null;} + reads++; + try {return Objects.requireNonNull(source.read(reference));} + catch(RuntimeException failure){return new Lookup(State.UNAVAILABLE,null);} + } + void gap(Key from,Reference reference,Reason reason){gaps.add(new Gap(from,reference,reason));} + } + private static void text(String value,int max) { + if(value==null||value.isBlank()||value.length()>max||value.codePoints().anyMatch(Character::isISOControl)) + throw new IllegalArgumentException("Invalid dependency identity"); + } + private static void digest(String value){if(value==null||!value.matches("[0-9a-f]{64}"))throw new IllegalArgumentException("Invalid dependency digest");} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/DependencyReviewSource.java b/backend/src/main/java/net/modtale/service/security/scan/DependencyReviewSource.java new file mode 100644 index 000000000..86c4ff73c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/DependencyReviewSource.java @@ -0,0 +1,153 @@ +package net.modtale.service.security.scan; + +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.ProjectDependency; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import static net.modtale.service.security.scan.DependencyReviewGraph.*; + +/** One bounded inspection session. Recorded declarations do not authorize publication. */ +public final class DependencyReviewSource implements Source { + private static final int MAX_BYTES=256*1024, MAX_VERSIONS=4096; + private final MongoCollection projects; + private final LongSupplier clock; + private final long started,budget; + private int queries; + public DependencyReviewSource(MongoTemplate mongo) {this(mongo,System::nanoTime,TimeUnit.SECONDS.toNanos(5));} + DependencyReviewSource(MongoTemplate mongo,LongSupplier clock,long budget) { + if(budget<=0||budget>TimeUnit.SECONDS.toNanos(5))throw new IllegalArgumentException("Invalid inspection budget"); + this.projects=mongo.getCollection("projects").withReadConcern(ReadConcern.MAJORITY).withReadPreference(ReadPreference.primary()); + this.clock=Objects.requireNonNull(clock);this.started=clock.getAsLong();this.budget=budget; + } + @Override public Lookup read(Reference reference) {return lookup(reference.projectId(),reference.versionNumber(),false);} + public Lookup readRoot(String projectId,String versionId) { + new Reference(projectId,versionId); + return lookup(projectId,versionId,true); + } + private Lookup lookup(String projectId,String selector,boolean byId) { + try { + var ids=new ArrayList();ids.add(projectId);if(ObjectId.isValid(projectId))ids.add(new ObjectId(projectId)); + var projection=new Document("_id",1).append("versions",new Document("$map",new Document("input",new Document("$slice",List.of("$versions",MAX_VERSIONS+1))) + .append("as","v").append("in",new Document("id","$$v._id").append("label","$$v.versionNumber").append("unique",uniqueNames("$$v"))))); + var roots=query(new Document("_id",new Document("$in",ids)),projection); + if(roots.isEmpty())return absent(State.MISSING); + if(roots.size()!=1)return absent(State.AMBIGUOUS); + var root=roots.getFirst();var versions=list(root.get("versions")); + if(versions.size()>MAX_VERSIONS)return absent(State.UNAVAILABLE); + int selected=-1;String id=null,label=null;Set versionIds=new HashSet<>(); + for(int i=0;i=0)return absent(State.AMBIGUOUS); + selected=i;id=candidateId;label=candidateLabel; + } + } + if(selected<0)return absent(State.MISSING); + var records=query(new Document("_id",root.get("_id")),new Document("_id",1) + .append("version",new Document("$arrayElemAt",List.of("$versions",selected)))); + if(records.size()!=1)return absent(State.UNAVAILABLE); + var v=document(records.getFirst().get("version")); + if(!id.equals(string(v,"_id"))||!label.equals(string(v,"versionNumber")))return absent(State.UNAVAILABLE); + var result=snapshot(projectId,v); + remaining(); + return new Lookup(State.FOUND,result); + } catch(AmbiguousRecord ambiguous) {return absent(State.AMBIGUOUS);} + catch(RuntimeException malformedOrUnavailable) {return absent(State.UNAVAILABLE);} + } + private List query(Document match,Document projection) { + if(++queries>256)throw new IllegalStateException("Inspection query limit exceeded"); + long millis=Math.max(1,TimeUnit.NANOSECONDS.toMillis(remaining())); + // Size-check on the server before a document crosses the wire. Missing/malformed arrays fail closed. + var bounded=new Document("$replaceWith",new Document("$cond",List.of( + new Document("$lte",List.of(new Document("$bsonSize","$$ROOT"),MAX_BYTES)),"$$ROOT",new Document("oversized",true)))); + // Projection would otherwise erase duplicate root fields before the driver could detect them. + projection.append("sourceUnique",uniqueNames("$$ROOT")); + var raw=projects.withDocumentClass(org.bson.RawBsonDocument.class).withTimeout(millis,TimeUnit.MILLISECONDS).aggregate(List.of(new Document("$match",match), + new Document("$limit",2),new Document("$project",projection),bounded)) + .collation(Collation.builder().locale("simple").build()).maxTime(millis,TimeUnit.MILLISECONDS).into(new ArrayList<>()); + remaining(); + var result=new ArrayList(); + for(var stored:raw) { + if(stored.getByteBuffer().remaining()>MAX_BYTES)throw new IllegalStateException("Inspection record exceeds limit"); + // Validate raw names before DocumentCodec can collapse repeated fields into map entries. + try(var reader=new org.bson.BsonBinaryReader(stored.getByteBuffer().asNIO())) {validateDocument(reader,0);} + var decoded=stored.decode(new org.bson.codecs.DocumentCodec()); + if(Boolean.FALSE.equals(decoded.get("sourceUnique")))throw new AmbiguousRecord(); + result.add(decoded); + } + if(result.stream().anyMatch(d->d.containsKey("oversized")))throw new IllegalStateException("Inspection record exceeds limit"); + return result; + } + private static Document uniqueNames(String expression) { + var names=new Document("$map",new Document("input",new Document("$objectToArray",expression)).append("as","field").append("in","$$field.k")); + return new Document("$eq",List.of(new Document("$size",names),new Document("$size",new Document("$setUnion",List.of(names,List.of()))))); + } + private void validateDocument(org.bson.BsonBinaryReader reader,int depth) { + if(depth>64)throw new IllegalArgumentException("Inspection document depth exceeds limit"); + remaining();reader.readStartDocument();var names=new HashSet(); + while(reader.readBsonType()!=org.bson.BsonType.END_OF_DOCUMENT) { + remaining();if(!names.add(reader.readName()))throw new AmbiguousRecord();validateValue(reader,depth+1); + } + reader.readEndDocument(); + } + private void validateValue(org.bson.BsonBinaryReader reader,int depth) { + if(depth>64)throw new IllegalArgumentException("Inspection document depth exceeds limit"); + remaining(); + if(reader.getCurrentBsonType()==org.bson.BsonType.DOCUMENT)validateDocument(reader,depth); + else if(reader.getCurrentBsonType()==org.bson.BsonType.ARRAY) { + reader.readStartArray(); + while(reader.readBsonType()!=org.bson.BsonType.END_OF_DOCUMENT)validateValue(reader,depth+1); + reader.readEndArray(); + } else reader.skipValue(); + } + private static final class AmbiguousRecord extends IllegalArgumentException {} + private long remaining() { + long remaining=budget-(clock.getAsLong()-started); + if(remaining<=0)throw new IllegalStateException("Inspection deadline exceeded");return remaining; + } + private static Snapshot snapshot(String projectId,Document v) { + var model=new ProjectVersion();model.setManifestId(optionalString(v,"manifestId"));model.setManifestVersion(optionalString(v,"manifestVersion")); + var games=new ArrayList(); + if(v.get("gameVersions")!=null)for(var game:list(v.get("gameVersions"))) { + if(!(game instanceof String value)||value.length()>256)throw new IllegalArgumentException("Invalid game version");games.add(value); + } + model.setGameVersions(games); + var dependencies=new ArrayList();var contextDependencies=new ArrayList(); + if(v.get("dependencies")!=null)for(var item:list(v.get("dependencies"))) { + var d=document(item); + var source=d.get("source")==null?ProjectDependency.Source.MODTALE:ProjectDependency.Source.valueOf(string(d,"source")); + var type=d.get("dependencyType")==null?ProjectDependency.DependencyType.REQUIRED:ProjectDependency.DependencyType.valueOf(string(d,"dependencyType")); + String target=string(d,"projectId"),pin=string(d,"versionNumber"); + dependencies.add(new Dependency(source,type,new Reference(target,pin))); + var context=new ProjectDependency(target,null,pin,type);context.setSource(source);contextDependencies.add(context); + } + model.setDependencies(contextDependencies); + String override=optionalString(v,"overrideFileUrl"); + boolean supplemental=override!=null&&!override.isBlank()||v.get("modpackConfigs")!=null&&!list(v.get("modpackConfigs")).isEmpty(); + RecordedScan scan=null; + if(v.get("scanResult")!=null) { + var stored=document(v.get("scanResult")); + if(stored.get("securityEvidence")!=null) { + var evidence=document(stored.get("securityEvidence")); + scan=new RecordedScan(string(evidence,"artifactSha256"),string(evidence,"contentSha256"),string(evidence,"policyVersion")); + } + } + return new Snapshot(projectId,string(v,"_id"),string(v,"versionNumber"),string(v,"fileUrl"),string(v,"hash"), + ArtifactReviewContext.fingerprint(model),scan,supplemental,dependencies); + } + private static Lookup absent(State state){return new Lookup(state,null);} + private static Document document(Object value){if(value instanceof Document document)return document;throw new IllegalArgumentException("Invalid inspection record");} + private static List list(Object value){if(value instanceof List list)return list;throw new IllegalArgumentException("Invalid inspection list");} + private static String string(Document d,String key){if(d.get(key) instanceof String s)return s;throw new IllegalArgumentException("Invalid inspection identity");} + private static String optionalString(Document d,String key){return d.get(key)==null?null:string(d,key);} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java new file mode 100644 index 000000000..4dc50ae96 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java @@ -0,0 +1,56 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.RemoteReviewBinding; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(name="app.warden.jobs.enabled",havingValue="true") +public final class RemoteReviewBootstrap { + public record Prepared(String state,RemoteReviewBinding binding) {} + private final RemoteReviewPersistence persistence; + private final RemoteReviewClient client; + private final RemoteReviewStep step; + public RemoteReviewBootstrap(RemoteReviewPersistence persistence,RemoteReviewClient client,RemoteReviewStep step) { + this.persistence=persistence;this.client=client;this.step=step; + } + public Prepared prepare(String projectId,String versionId,int attempt,String requestId) {return prepare(projectId,versionId,attempt,requestId,()->true);} + private Prepared prepare(String projectId,String versionId,int attempt,String requestId,java.util.function.BooleanSupplier running) { + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + var current=persistence.current(projectId,versionId,attempt,requestId); + if(current==null)return new Prepared("NO_WORK",null); + if(current.getScanResult().getRemoteReview()!=null || "REMOTE_REVIEW".equals(current.getScanResult().getScanState())) + return retainedOrBroken(projectId,versionId,attempt,requestId,running); + String context=ArtifactReviewContext.automaticallyReviewableFingerprint(current); + if(context==null) { + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + return new Prepared(persistence.finishUnsupportedContext(projectId,current)?"UNAVAILABLE":"NO_WORK",null); + } + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + var configuration=client.configuration(); + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + var binding=new RemoteReviewBinding(projectId,versionId,requestId,attempt,current.getFileUrl(),current.getHash(),context, + configuration.policyVersion(),configuration.reviewConfigSha256(),null,current.getScanResult().isManualRescan(),configuration.origin()); + try { persistence.bind(current,binding); } + catch(RuntimeException unknown) { + var recovered=retainedOrBroken(projectId,versionId,attempt,requestId,running); + if(!"NO_WORK".equals(recovered.state()))return recovered; + throw unknown; + } + return retainedOrBroken(projectId,versionId,attempt,requestId,running); + } + private Prepared retainedOrBroken(String projectId,String versionId,int attempt,String requestId,java.util.function.BooleanSupplier running) { + var retained=persistence.retained(projectId,versionId,attempt,requestId); + if(retained!=null && retained.origin()!=null)return new Prepared("READY",retained); + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + return new Prepared(persistence.finishBrokenBinding(projectId,versionId,attempt,requestId)==null?"NO_WORK":"UNAVAILABLE",null); + } + public RemoteReviewStep.Outcome advance(String projectId,String versionId,int attempt,String requestId) {return advance(projectId,versionId,attempt,requestId,()->true);} + public RemoteReviewStep.Outcome advance(String projectId,String versionId,int attempt,String requestId,java.util.function.BooleanSupplier running) { + try { + var prepared=prepare(projectId,versionId,attempt,requestId,running); + return prepared.binding()==null?new RemoteReviewStep.Outcome(prepared.state(),null):step.advance(prepared.binding(),running); + } catch(RemoteReviewClient.Superseded stopped) {return new RemoteReviewStep.Outcome("SHUTDOWN",null); + } catch(RuntimeException unavailable) {return new RemoteReviewStep.Outcome("RETRY",null);} + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java new file mode 100644 index 000000000..b1a5f91f7 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java @@ -0,0 +1,175 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.databind.*; +import net.modtale.config.properties.AppWardenProperties; +import net.modtale.model.project.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.core.publisher.Sinks; +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +@ConditionalOnProperty(name="app.warden.jobs.enabled",havingValue="true") +public final class RemoteReviewClient implements AutoCloseable { + public record Configuration(String policyVersion,String reviewConfigSha256,RemoteReviewOrigin origin) {} + public record Status(String jobId,String state,boolean artifactRetained,long createdAt,long expiresAt,String workState) {} + public static final class Unavailable extends RuntimeException { + private final int status; + Unavailable(int status) { super("Remote review service unavailable ("+status+")");this.status=status; } + public int status() { return status; } + } + private final WebClient client; + private final ConnectionProvider connections; + private final Semaphore slots=new Semaphore(2); + private final AtomicBoolean closed=new AtomicBoolean(); + private final Sinks.Empty stop=Sinks.empty(); + private final Duration timeout; + private final ObjectMapper mapper=new ObjectMapper(JsonFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(64).maxStringLength(16*1024*1024).maxNumberLength(20).build()).build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + @org.springframework.beans.factory.annotation.Autowired + public RemoteReviewClient(AppWardenProperties properties) { this(properties,Duration.ofSeconds(30)); } + RemoteReviewClient(AppWardenProperties properties,Duration timeout) { + URI base=URI.create(properties.url()); + if (!properties.enabled() || properties.apiKey()==null || properties.apiKey().isBlank() + || base.getHost()==null || !Set.of("http","https").contains(base.getScheme()) || base.getUserInfo()!=null + || base.getQuery()!=null || base.getFragment()!=null || !(base.getPath().isEmpty() || base.getPath().equals("/")) + || timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofSeconds(30))>0) + throw new IllegalArgumentException("Invalid remote review configuration"); + this.timeout=timeout; + connections=ConnectionProvider.builder("remote-review").maxConnections(2).pendingAcquireMaxCount(2) + .pendingAcquireTimeout(timeout).build(); + var http=reactor.netty.http.client.HttpClient.create(connections).disableRetry(true).followRedirect(false) + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,5000).responseTimeout(timeout); + client=WebClient.builder().baseUrl(base.toString()).clientConnector(new ReactorClientHttpConnector(http)) + .defaultHeader("X-Warden-Api-Key",properties.apiKey()) + .codecs(c->c.defaultCodecs().maxInMemorySize(16*1024*1024)).build(); + } + public Configuration configuration() {return configuration(()->true,timeout::toNanos);} + public Configuration configuration(java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + var origin=origin(current,remainingNanos); + var body=exchange(scoped(client.get().uri("/api/v1/review-jobs/configuration"),origin),200,65536,current,remainingNanos); + fields(body,"policyVersion","reviewConfigSha256");String policy=text(body,"policyVersion"),config=text(body,"reviewConfigSha256"); + if (!policy.matches("warden-3\\.0\\.0:[0-9a-f]{64}") || !digest(config)) throw new Unavailable(502); + return new Configuration(policy,config,origin); + } + public RemoteReviewOrigin origin() {return origin(()->true,timeout::toNanos);} + private RemoteReviewOrigin origin(java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + var body=exchange(client.get().uri("/api/v1/review-jobs/identity"),200,65536,current,remainingNanos);fields(body,"deploymentId","callerScope"); + try{return new RemoteReviewOrigin(text(body,"deploymentId"),text(body,"callerScope"));} + catch(IllegalArgumentException invalid){throw new Unavailable(502);} + } + public static final class MissingOrigin extends RuntimeException {public MissingOrigin(){super("Remote review origin is unavailable");}} + private static WebClient.RequestHeadersSpec scoped(WebClient.RequestHeadersSpec request,RemoteReviewOrigin origin) { + if(origin==null)throw new MissingOrigin(); + return request.header("X-Warden-Deployment-Id",origin.deploymentId()).header("X-Warden-Caller-Scope",origin.callerScope()); + } + public Optional find(RemoteReviewBinding binding) { + try { return Optional.of(statusBody(exchange(scoped(client.get().uri(uri(binding,"/requests/"+binding.requestId(),false)),binding.origin()),200,65536),binding)); } + catch(Unavailable failure) { if(failure.status()==404)return Optional.empty();throw failure; } + } + public static final class Superseded extends RuntimeException { public Superseded(){super("Remote review ownership changed");} } + public Status submitOrFind(RemoteReviewBinding binding,byte[] bytes) { + if(binding.jobId()==null)validateOriginal(binding,bytes); + return submitOrFind(binding,()->bytes,()->true); + } + public Status submitOrFind(RemoteReviewBinding binding,java.util.function.Supplier original,java.util.function.BooleanSupplier current) { + if(!current.getAsBoolean())throw new Superseded(); + var found=binding.jobId()!=null?Optional.of(status(binding)):find(binding); + if(!current.getAsBoolean())throw new Superseded(); + if(found.isPresent() && !found.get().state().equals("AWAITING_UPLOAD"))return found.get(); + byte[] bytes=original.get();validateOriginal(binding,bytes); + if(!current.getAsBoolean())throw new Superseded(); + var form=new MultipartBodyBuilder();form.part("requestId",binding.requestId());form.part("artifactSha256",binding.artifactSha256()); + form.part("contextSha256",binding.contextSha256());form.part("policyVersion",binding.policyVersion());form.part("reviewConfigSha256",binding.reviewConfigSha256()); + form.part("file",new ByteArrayResource(bytes){@Override public String getFilename(){return "artifact.zip";}}); + return statusBody(exchange(scoped(client.post().uri("/api/v1/review-jobs").contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(form.build())),binding.origin()),202,65536,current),found.isPresent()?binding.withJobId(found.get().jobId()):binding); + } + private static void validateOriginal(RemoteReviewBinding binding,byte[] bytes) { + if(bytes==null || bytes.length==0 || bytes.length>100*1024*1024 || !binding.artifactSha256().equals(hash(bytes))) + throw new IllegalArgumentException("Original artifact does not match review binding"); + } + public Status status(RemoteReviewBinding binding) {return status(binding,()->true,timeout::toNanos);} + public Status status(RemoteReviewBinding binding,java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + requireJob(binding);return statusBody(exchange(scoped(client.get().uri(uri(binding,"/"+binding.jobId(),true)),binding.origin()),200,65536,current,remainingNanos),binding); + } + public Status cancel(RemoteReviewBinding binding) {return cancel(binding,()->true,timeout::toNanos);} + public Status cancel(RemoteReviewBinding binding,java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + requireJob(binding);return statusBody(exchange(scoped(client.delete().uri(uri(binding,"/"+binding.jobId(),true)),binding.origin()),200,65536,current,remainingNanos),binding); + } + public ScanResult result(RemoteReviewBinding binding) { + requireJob(binding);var body=exchange(scoped(client.get().uri(uri(binding,"/"+binding.jobId()+"/result",true)),binding.origin()),200,16*1024*1024); + fields(body,"jobId","requestId","binding","completedAt","scan");identity(body,binding); + if(number(body,"completedAt")<=0 || !body.path("scan").isObject())throw new Unavailable(502); + try { + var scan=mapper.treeToValue(body.get("scan"),ScanResult.class);var evidence=scan.getSecurityEvidence(); + if(evidence==null || !binding.artifactSha256().equals(evidence.artifactSha256()) || !binding.policyVersion().equals(evidence.policyVersion()))throw new Unavailable(502); + scan.setArtifactVerified(evidence.complete());scan.setReviewedContextSha256(binding.contextSha256());scan.setScanRequestId(binding.requestId()); + return scan; + }catch(Unavailable failure){throw failure;}catch(Exception invalid){throw new Unavailable(502);} + } + private Status statusBody(JsonNode body,RemoteReviewBinding binding) { + fields(body,"jobId","requestId","binding","state","artifactRetained","createdAt","expiresAt","workState");identity(body,binding); + String state=text(body,"state");long created=number(body,"createdAt"),expires=number(body,"expiresAt"); + if(!Set.of("QUEUED","RUNNING","COMPLETED","CANCELLED","EXPIRED","HELD","UPLOADING","AWAITING_UPLOAD").contains(state) + || !body.path("artifactRetained").isBoolean() || created<=0 || expires<=created + || !(body.path("workState").isNull() || body.path("workState").isTextual() && body.path("workState").textValue().length()<=128))throw new Unavailable(502); + boolean retained=body.get("artifactRetained").booleanValue(); + if(Set.of("QUEUED","RUNNING","COMPLETED","HELD").contains(state)&&!retained)throw new Unavailable(502); + return new Status(text(body,"jobId"),state,retained,created,expires,body.get("workState").isNull()?null:body.get("workState").textValue()); + } + private void identity(JsonNode body,RemoteReviewBinding expected) { + String job=text(body,"jobId"); + if(!job.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || expected.jobId()!=null&&!expected.jobId().equals(job) || !expected.requestId().equals(text(body,"requestId")))throw new Unavailable(502); + var b=body.path("binding");fields(b,"artifactSha256","contextSha256","policyVersion","reviewConfigSha256"); + if(!expected.artifactSha256().equals(text(b,"artifactSha256")) || !expected.contextSha256().equals(text(b,"contextSha256")) + || !expected.policyVersion().equals(text(b,"policyVersion")) || !expected.reviewConfigSha256().equals(text(b,"reviewConfigSha256")))throw new Unavailable(502); + } + private JsonNode exchange(WebClient.RequestHeadersSpec request,int expected,int max) {return exchange(request,expected,max,()->true);} + private JsonNode exchange(WebClient.RequestHeadersSpec request,int expected,int max,java.util.function.BooleanSupplier current) { + return exchange(request,expected,max,current,timeout::toNanos); + } + private JsonNode exchange(WebClient.RequestHeadersSpec request,int expected,int max,java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + if(closed.get() || !slots.tryAcquire())throw new Unavailable(503); + try { + if(closed.get())throw new Unavailable(503); + if(!current.getAsBoolean())throw new Superseded(); + long remaining=Math.min(timeout.toNanos(),remainingNanos.getAsLong());if(remaining<=0)throw new Superseded(); + byte[] bytes=request.exchangeToMono(response->{ + if(response.statusCode().value()!=expected)return response.releaseBody().then(reactor.core.publisher.Mono.error(new Unavailable(response.statusCode().value()))); + return response.bodyToMono(byte[].class); + }).takeUntilOther(stop.asMono()).timeout(Duration.ofNanos(remaining)).block(); + if(closed.get() || bytes==null || bytes.length==0 || bytes.length>max)throw new Unavailable(502); + var parsed=mapper.readTree(bytes);if(parsed==null || !parsed.isObject())throw new Unavailable(502);return parsed; + }catch(Unavailable | Superseded failure){throw failure;}catch(Exception failure){throw new Unavailable(503);}finally{slots.release();} + } + private static String uri(RemoteReviewBinding b,String suffix,boolean request) { + return "/api/v1/review-jobs"+suffix+"?artifactSha256="+b.artifactSha256()+"&contextSha256="+b.contextSha256() + +"&policyVersion="+b.policyVersion()+"&reviewConfigSha256="+b.reviewConfigSha256()+(request?"&requestId="+b.requestId():""); + } + private static void requireJob(RemoteReviewBinding binding){if(binding.jobId()==null)throw new IllegalArgumentException("Remote job identity is required");} + private static void fields(JsonNode node,String... fields) { + if(!node.isObject() || node.size()!=fields.length)throw new Unavailable(502); + for(String field:fields)if(!node.has(field))throw new Unavailable(502); + } + private static String text(JsonNode node,String key){if(!node.path(key).isTextual())throw new Unavailable(502);return node.get(key).textValue();} + private static long number(JsonNode node,String key){if(!node.path(key).isIntegralNumber()||!node.get(key).canConvertToLong())throw new Unavailable(502);return node.get(key).longValue();} + private static boolean digest(String value){return value.matches("[0-9a-f]{64}");} + private static String hash(byte[] bytes){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));}catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);}} + @Override @jakarta.annotation.PreDestroy public void close(){if(closed.compareAndSet(false,true)){stop.tryEmitEmpty();connections.dispose();}} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java new file mode 100644 index 000000000..d0087444a --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java @@ -0,0 +1,83 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.Project; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +public final class RemoteReviewDiscovery { + public record Cursor(Object projectId,int offset,boolean afterProject) { + public Cursor { + if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.codePointCount(0,s.length())<=128) + || offset<0 || offset>16*1024*1024 || afterProject && offset!=0)throw new IllegalArgumentException("Invalid discovery cursor"); + } + } + public record Candidate(String projectId,String versionId,int attempt,String requestId) {} + public record Page(List candidates,Cursor next,int examined) { public Page { candidates=List.copyOf(candidates); } } + private final MongoCollection projects; + public RemoteReviewDiscovery(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(Cursor cursor,int limit) { + if(limit<1 || limit>64)throw new IllegalArgumentException("Invalid discovery page size"); + var eligibility=new Document("reviewStatus","PENDING").append("scanResult.status","SCANNING") + .append("scanResult.scanState",new Document("$in",List.of("QUEUED","SCANNING","REMOTE_REVIEW"))); + var terms=new ArrayList();terms.add(boundedString("$_id",128,true)); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))).append("versions",new Document("$elemMatch",eligibility)) + .append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(new Document("$eq",List.of("$_id",literal(cursor.projectId()))),cursor.offset(),0)); + var array=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var view=new Document("versionId",safeString("$$v._id",128)).append("requestId",safeString("$$v.scanResult.scanRequestId",36)) + .append("attempt",new Document("$cond",Arrays.asList(new Document("$in",List.of(new Document("$type","$$v.scanResult.scanAttempt"),List.of("int","long"))),"$$v.scanResult.scanAttempt",null))) + .append("eligible",eligible()); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",array)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(array,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=projects.aggregate(pipeline).collation(Collation.builder().locale("simple").build()).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()) {root=rows.hasNext()?rows.next():null;} + if(root==null)return new Page(List.of(),null,0); + Object id=root.get("_id");String projectId=id instanceof ObjectId objectId?objectId.toHexString():(String)id; + var items=root.getList("items",Document.class);var candidates=new ArrayList(); + for(var item:items) { + if(!Boolean.TRUE.equals(item.get("eligible")))continue; + String version=item.getString("versionId"),request=item.getString("requestId");Object attempt=item.get("attempt"); + if(version==null || version.isBlank() || version.chars().anyMatch(Character::isISOControl) || request==null + || !request.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || !(attempt instanceof Number number) || number.longValue()<1 || number.longValue()>Integer.MAX_VALUE)continue; + candidates.add(new Candidate(projectId,version,((Number)attempt).intValue(),request)); + } + int end=Math.addExact(root.getInteger("offset"),items.size()); + var next=end(base);conditions.add(available);return new Document("$and",conditions); + } + private static Document safeString(String field,int max) {return new Document("$cond",Arrays.asList(boundedString(field,max,false),field,null));} + private static Document boundedString(String field,int max,boolean objectIdAllowed) { + return new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type",field),"string")), + new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP",field),0)),new Document("$lte",List.of(new Document("$strLenCP",field),max)))), + objectIdAllowed?new Document("$eq",List.of(new Document("$type",field),"objectId")):false)); + } + private static Document literal(Object value){return new Document("$literal",value);} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java new file mode 100644 index 000000000..0a865884b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java @@ -0,0 +1,141 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; +import org.springframework.stereotype.Service; +import java.util.Objects; + +@Service +public class RemoteReviewPersistence { + private final MongoTemplate mongo; + public RemoteReviewPersistence(MongoTemplate mongo) { + this.mongo = new MongoTemplate(mongo.getMongoDatabaseFactory(),mongo.getConverter()); + this.mongo.setWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true) + .withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)); + } + + public boolean bind(ProjectVersion observed, RemoteReviewBinding binding) { + if (binding == null || binding.origin()==null || binding.jobId() != null || !matches(observed,binding)) return false; + var version = target(observed,binding).and("scanResult.scanState").in("QUEUED","SCANNING","REMOTE_REVIEW") + .orOperator(Criteria.where("scanResult.remoteReview").is(null),Criteria.where("scanResult.remoteReview").is(binding)); + return mongo.updateFirst(query(binding,version), new Update() + .set("versions.$.scanResult.remoteReview",binding) + .set("versions.$.scanResult.scanState","REMOTE_REVIEW"),Project.class).getMatchedCount() == 1; + } + + public boolean attachJob(ProjectVersion observed, RemoteReviewBinding binding, String jobId) { + if (binding == null || binding.origin()==null || !matches(observed,binding)) return false; + var attached = binding.withJobId(jobId); + var version = target(observed,binding).and("scanResult.scanState").is("REMOTE_REVIEW").and("scanResult.remotePoll").exists(false) + .orOperator(Criteria.where("scanResult.remoteReview").is(binding),Criteria.where("scanResult.remoteReview").is(attached)); + return mongo.updateFirst(query(binding,version),new Update() + .set("versions.$.scanResult.remoteReview",attached),Project.class).getMatchedCount() == 1; + } + + public boolean finishUnsupportedContext(String projectId,ProjectVersion observed) { + if(projectId==null || observed==null || observed.getId()==null || observed.getScanResult()==null + || observed.getReviewStatus()!=ProjectVersion.ReviewStatus.PENDING + || ArtifactReviewContext.automaticallyReviewableFingerprint(observed)!=null)return false; + var scan=observed.getScanResult(); + if(scan.getStatus()!=ScanStatus.SCANNING || !java.util.Set.of("QUEUED","SCANNING").contains(Objects.toString(scan.getScanState(),"")) + || scan.getRemoteReview()!=null || scan.getScanAttempt()<1 || scan.getScanRequestId()==null + || !scan.getScanRequestId().matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))return false; + var version=ArtifactReviewContext.bindSnapshot(Criteria.where("_id").is(observed.getId()) + .and("hash").is(observed.getHash()).and("fileUrl").is(observed.getFileUrl()) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) + .and("scanResult.status").is(ScanStatus.SCANNING).and("scanResult.scanState").is(scan.getScanState()).and("scanResult.verdict").is(scan.getVerdict()) + .and("scanResult.scanRequestId").is(scan.getScanRequestId()).and("scanResult.scanAttempt").is(scan.getScanAttempt()) + .and("scanResult.manualRescan").in(scan.isManualRescan()?java.util.List.of(true):java.util.Arrays.asList(false,null)) + .and("scanResult.remoteReview").is(null).and("scanResult.remotePoll").exists(false),observed); + return mongo.updateFirst(query(projectId,observed.getId(),version),new Update() + .set("versions.$.scanResult.status",ScanStatus.FAILED).set("versions.$.scanResult.scanState","REMOTE_UNSUPPORTED_CONTEXT") + .set("versions.$.scanResult.verdict","BLOCK".equals(scan.getVerdict())?"BLOCK":"REVIEW") + .set("versions.$.scanResult.scanTimestamp",System.currentTimeMillis()) + .set("versions.$.scanResult.securityEvidence",null).set("versions.$.scanResult.artifactVerified",false) + .set("versions.$.scanResult.reviewedContextSha256",null).set("versions.$.scanResult.reusedReviewVersion",null) + .set("versions.$.scanResult.reusedReviewOrigins",null).set("versions.$.scanResult.holdUntilTimestamp",0) + .set("versions.$.scanResult.reviewerNotes",java.util.List.of("Security review cannot cover this version's current dependencies, runtime metadata or supplemental content. Resolve the review context before requesting another scan. No security clearance was granted.")),Project.class) + .getModifiedCount()==1; + } + + private record Current(org.bson.Document projectQuery,org.bson.Document rawVersion,ProjectVersion version) {} + public ProjectVersion current(String projectId,String versionId,int attempt,String requestId) { + var snapshot=readCurrent(projectId,versionId,attempt,requestId);return snapshot==null?null:snapshot.version(); + } + private Current readCurrent(String projectId,String versionId,int attempt,String requestId) { + if(projectId==null || versionId==null || attempt<1 || requestId==null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))return null; + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var query=new org.springframework.data.mongodb.core.convert.QueryMapper(mongo.getConverter()).getMappedObject(new org.bson.Document("_id",projectId),entity); + var raw=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(com.mongodb.ReadPreference.primary()).withReadConcern(com.mongodb.ReadConcern.MAJORITY) + .find(query).collation(com.mongodb.client.model.Collation.builder().locale("simple").build()).maxTime(5,java.util.concurrent.TimeUnit.SECONDS).first(); + if(raw==null)return null;var versions=raw.getList("versions",org.bson.Document.class);if(versions==null)return null; + var selected=versions.stream().filter(v->versionId.equals(v.get("_id"))).toList();if(selected.size()!=1)return null; + ProjectVersion version;try{version=mongo.getConverter().read(ProjectVersion.class,selected.getFirst());}catch(RuntimeException invalid){return null;} + var scan=version.getScanResult(); + if(version.getReviewStatus()!=ProjectVersion.ReviewStatus.PENDING || scan==null || scan.getStatus()!=ScanStatus.SCANNING + || !java.util.Set.of("QUEUED","SCANNING","REMOTE_REVIEW").contains(Objects.toString(scan.getScanState(),"")) + || !requestId.equals(scan.getScanRequestId()) || attempt!=scan.getScanAttempt())return null; + return new Current(query,selected.getFirst(),version); + } + public String finishBrokenBinding(String projectId,String versionId,int attempt,String requestId) { + var snapshot=readCurrent(projectId,versionId,attempt,requestId);if(snapshot==null)return null; + var scan=snapshot.version().getScanResult();var binding=scan.getRemoteReview(); + String reason; + if(binding==null) { + if(!"REMOTE_REVIEW".equals(scan.getScanState()))return null; + reason="REMOTE_BINDING_MISSING"; + } else { + if(projectId.equals(binding.projectId()) && "REMOTE_REVIEW".equals(scan.getScanState()) && matches(snapshot.version(),binding)) { + if(binding.origin()!=null)return null; + reason="REMOTE_ORIGIN_UNVERIFIED"; + } else reason="REMOTE_BINDING_MISMATCH"; + } + var uniqueness=new org.bson.Document("$eq",java.util.List.of(new org.bson.Document("$size",new org.bson.Document("$filter", + new org.bson.Document("input","$versions").append("as","v").append("cond",new org.bson.Document("$eq",java.util.List.of("$$v._id",new org.bson.Document("$literal",versionId)))))),1)); + var query=new org.bson.Document(snapshot.projectQuery()).append("versions",snapshot.rawVersion()).append("$expr",uniqueness); + var fields=new org.bson.Document("status","FAILED").append("scanState",reason) + .append("verdict","BLOCK".equals(scan.getVerdict())?"BLOCK":"REVIEW").append("scanTimestamp",new org.bson.Document("$toLong","$$NOW")) + .append("securityEvidence",null).append("artifactVerified",false).append("reviewedContextSha256",null) + .append("reusedReviewVersion",null).append("reusedReviewOrigins",null).append("holdUntilTimestamp",0) + .append("remotePoll",null).append("reviewerNotes",java.util.List.of(reason.equals("REMOTE_ORIGIN_UNVERIFIED")?"The original review service identity was not recorded. Reconcile the original job before requesting another scan. No security clearance was granted.":"The stored review job no longer matches this version or its binding is missing. Repair review state before requesting another scan. No security clearance was granted.")); + var updated=new org.bson.Document("$mergeObjects",java.util.List.of("$$v",new org.bson.Document("scanResult",new org.bson.Document("$mergeObjects",java.util.List.of("$$v.scanResult",fields))))); + var patch=java.util.List.of(new org.bson.Document("$set",new org.bson.Document("versions",new org.bson.Document("$map",new org.bson.Document("input","$versions").append("as","v") + .append("in",new org.bson.Document("$cond",java.util.List.of(new org.bson.Document("$eq",java.util.List.of("$$v",new org.bson.Document("$literal",snapshot.rawVersion()))),updated,"$$v"))))))); + var result=mongo.getCollection(mongo.getCollectionName(Project.class)).withWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true) + .withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)).updateOne(query,patch,new com.mongodb.client.model.UpdateOptions().collation(com.mongodb.client.model.Collation.builder().locale("simple").build())); + return result.getModifiedCount()==1?reason:null; + } + public RemoteReviewBinding retained(String projectId,String versionId,int attempt,String requestId) { + var current=current(projectId,versionId,attempt,requestId);if(current==null)return null; + var binding=current.getScanResult().getRemoteReview(); + return binding!=null && projectId.equals(binding.projectId()) && "REMOTE_REVIEW".equals(current.getScanResult().getScanState()) && matches(current,binding)?binding:null; + } + + private static boolean matches(ProjectVersion observed,RemoteReviewBinding binding) { + if (observed == null || observed.getScanResult() == null) return false; + var scan=observed.getScanResult(); + return Objects.equals(observed.getId(),binding.versionId()) && Objects.equals(observed.getHash(),binding.artifactSha256()) + && Objects.equals(observed.getFileUrl(),binding.filePath()) + && Objects.equals(ArtifactReviewContext.automaticallyReviewableFingerprint(observed),binding.contextSha256()) + && Objects.equals(scan.getScanRequestId(),binding.requestId()) && scan.getScanAttempt() == binding.attempt() && scan.isManualRescan()==binding.manualRescan(); + } + private static Criteria target(ProjectVersion observed,RemoteReviewBinding binding) { + return ArtifactReviewContext.bindSnapshot(Criteria.where("_id").is(binding.versionId()) + .and("hash").is(binding.artifactSha256()).and("fileUrl").is(binding.filePath()) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) + .and("scanResult.status").is(ScanStatus.SCANNING) + .and("scanResult.scanRequestId").is(binding.requestId()) + .and("scanResult.scanAttempt").is(binding.attempt()) + .and("scanResult.manualRescan").in(binding.manualRescan()?java.util.List.of(true):java.util.Arrays.asList(false,null)),observed); + } + private static Query query(RemoteReviewBinding binding,Criteria version) { + return query(binding.projectId(),binding.versionId(),version); + } + private static Query query(String projectId,String versionId,Criteria version) { + var query=Query.query(Criteria.where("_id").is(projectId).and("versions").elemMatch(version)); + query.addCriteria(Criteria.where("$expr").is(new org.bson.Document("$eq",java.util.List.of(new org.bson.Document("$size",new org.bson.Document("$filter", + new org.bson.Document("input","$versions").append("as","v").append("cond",new org.bson.Document("$eq",java.util.List.of("$$v._id",new org.bson.Document("$literal",versionId)))))),1)))); + return query.collation(Collation.of("simple")); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java new file mode 100644 index 000000000..86e8e9f4b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java @@ -0,0 +1,137 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import net.modtale.model.project.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +public final class RemoteReviewPollStore { + public record Claim(RemoteReviewBinding binding,String token) { + public Claim { if(binding==null || token==null || !token.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw new IllegalArgumentException("Invalid poll claim"); } + } + private record Snapshot(Document project,Document version,Document scan,Document poll) {} + private static final com.mongodb.client.model.Collation BINARY=com.mongodb.client.model.Collation.builder().locale("simple").build(); + private final MongoTemplate mongo; + private final MongoCollection projects; + public RemoteReviewPollStore(MongoTemplate mongo) { + this.mongo=mongo; + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()) + .withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,TimeUnit.SECONDS)); + } + public Claim claim(RemoteReviewBinding binding,long leaseMillis) { + if(leaseMillis<1000 || leaseMillis>120000)throw new IllegalArgumentException("Invalid poll lease"); + if(binding.origin()==null)return null; + var snapshot=read(binding);if(snapshot==null)return null; + String token=UUID.randomUUID().toString();Date lease=date(snapshot.poll(),"leaseUntil"),next=date(snapshot.poll(),"nextPollAt"); + if(lease==null || next==null)return null; + var query=query(snapshot,binding,List.of(new Document("$lte",List.of(lease,"$$NOW")),new Document("$lte",List.of(next,"$$NOW")))); + var poll=new Document("token",token).append("leaseUntil",new Document("$add",List.of("$$NOW",leaseMillis))).append("nextPollAt",literal(next)); + if(projects.updateOne(query,patch(snapshot,new Document("remotePoll",poll)),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()!=1)return null; + return new Claim(binding,token); + } + public boolean isCurrent(Claim claim) { + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + return projects.find(liveQuery(snapshot,claim)).collation(BINARY).projection(new Document("_id",1)).maxTime(5,TimeUnit.SECONDS).first()!=null; + } + public Claim attachJob(Claim claim,String jobId) { + var attached=claim.binding().withJobId(jobId);var snapshot=liveSnapshot(claim);if(snapshot==null)return null; + var binding=new Document(snapshot.scan().get("remoteReview",Document.class));binding.put("jobId",jobId); + if(projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,new Document("remoteReview",literal(binding))),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getMatchedCount()!=1)return null; + return new Claim(attached,claim.token()); + } + public boolean release(Claim claim,long delayMillis) { + if(delayMillis<100 || delayMillis>3600000)throw new IllegalArgumentException("Invalid poll delay"); + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var poll=new Document("token",null).append("leaseUntil",literal(new Date(0))).append("nextPollAt",new Document("$add",List.of("$$NOW",delayMillis))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,new Document("remotePoll",poll)),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + public boolean recordStatusAndRelease(Claim claim,RemoteReviewClient.Status status,long delayMillis) { + if(delayMillis<100 || delayMillis>3600000)throw new IllegalArgumentException("Invalid poll delay"); + if(status==null || !Objects.equals(claim.binding().jobId(),status.jobId()) || status.jobId()==null + || !Set.of("QUEUED","RUNNING","COMPLETED","UPLOADING","AWAITING_UPLOAD").contains(status.state()) + || status.createdAt()<=0 || status.expiresAt()<=status.createdAt() || status.workState()!=null && status.workState().length()>128 + || Set.of("QUEUED","RUNNING","COMPLETED","HELD").contains(status.state()) && !status.artifactRetained())return false; + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var value=new Document("jobId",status.jobId()).append("state",status.state()).append("artifactRetained",status.artifactRetained()) + .append("createdAt",status.createdAt()).append("expiresAt",status.expiresAt()).append("workState",status.workState()); + var poll=new Document("token",null).append("leaseUntil",literal(new Date(0))).append("nextPollAt",new Document("$add",List.of("$$NOW",delayMillis))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,new Document("remoteStatus",literal(value)).append("remotePoll",poll)), + new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + public boolean finishUnavailable(Claim claim,RemoteReviewClient.Status status) { + if(status==null || status.state()==null || !Set.of("CANCELLED","EXPIRED","HELD").contains(status.state()) + || status.jobId()==null || !status.jobId().equals(claim.binding().jobId()) + || status.createdAt()<=0 || status.expiresAt()<=status.createdAt() + || status.workState()!=null && status.workState().length()>128 + || "HELD".equals(status.state()) && !status.artifactRetained())return false; + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var value=new Document("jobId",status.jobId()).append("state",status.state()).append("artifactRetained",status.artifactRetained()) + .append("createdAt",status.createdAt()).append("expiresAt",status.expiresAt()).append("workState",status.workState()); + var fields=new Document("remoteStatus",literal(value)).append("remotePoll",literal(null)) + .append("status","FAILED").append("scanState","REMOTE_"+status.state()).append("verdict","BLOCK".equals(snapshot.scan().get("verdict"))?"BLOCK":"REVIEW") + .append("scanTimestamp",new Document("$toLong","$$NOW")) + .append("securityEvidence",literal(null)).append("artifactVerified",false) + .append("reviewedContextSha256",literal(null)).append("reusedReviewVersion",literal(null)) + .append("reusedReviewOrigins",literal(null)).append("holdUntilTimestamp",0) + .append("reviewerNotes",literal(List.of("Security review did not complete ("+status.state().toLowerCase(Locale.ROOT)+"). No security clearance was granted."))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,fields),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + public boolean finishContextConflict(Claim claim) { + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var fields=new Document("remotePoll",literal(null)).append("status","FAILED").append("scanState","REMOTE_CONTEXT_CONFLICT") + .append("verdict","BLOCK".equals(snapshot.scan().get("verdict"))?"BLOCK":"REVIEW") + .append("scanTimestamp",new Document("$toLong","$$NOW")).append("securityEvidence",literal(null)).append("artifactVerified",false) + .append("reviewedContextSha256",literal(null)).append("reusedReviewVersion",literal(null)).append("reusedReviewOrigins",literal(null)).append("holdUntilTimestamp",0) + .append("reviewerNotes",literal(List.of("The review service rejected the stored request context or service identity. Reconcile the original job before requesting another scan. No security clearance was granted."))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,fields),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + private Snapshot liveSnapshot(Claim claim) { + var snapshot=read(claim.binding());return snapshot!=null && snapshot.poll()!=null && claim.token().equals(snapshot.poll().getString("token"))?snapshot:null; + } + private Document liveQuery(Snapshot snapshot,Claim claim) { + return query(snapshot,claim.binding(),List.of(new Document("$gt",List.of(date(snapshot.poll(),"leaseUntil"),"$$NOW")))); + } + private Snapshot read(RemoteReviewBinding binding) { + Objects.requireNonNull(binding); + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var id=new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id",binding.projectId()),entity); + var project=projects.find(id).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();if(project==null)return null; + var versions=project.getList("versions",Document.class);if(versions==null)return null; + var candidates=versions.stream().filter(v->binding.versionId().equals(v.get("_id"))).toList();if(candidates.size()!=1)return null; + var raw=candidates.getFirst();ProjectVersion version; + try {version=mongo.getConverter().read(ProjectVersion.class,raw);}catch(RuntimeException invalid){return null;} + var scan=version.getScanResult(); + if(version.getReviewStatus()!=ProjectVersion.ReviewStatus.PENDING || scan==null || scan.getStatus()!=ScanStatus.SCANNING + || !"REMOTE_REVIEW".equals(scan.getScanState()) || !binding.equals(scan.getRemoteReview()) + || !binding.requestId().equals(scan.getScanRequestId()) || binding.attempt()!=scan.getScanAttempt() || binding.manualRescan()!=scan.isManualRescan() + || !binding.artifactSha256().equals(version.getHash()) || !binding.filePath().equals(version.getFileUrl()) + || !binding.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(version)))return null; + var rawScan=raw.get("scanResult",Document.class);Document poll; + try {poll=rawScan.get("remotePoll",Document.class);}catch(ClassCastException invalid){return null;} + if(poll!=null && (poll.size()!=3 || !poll.containsKey("token") || date(poll,"leaseUntil")==null || date(poll,"nextPollAt")==null + || poll.get("token")!=null && !(poll.get("token") instanceof String token && token.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))))return null; + if(rawScan.containsKey("remotePoll") && poll==null)return null; + return new Snapshot(id,raw,rawScan,poll); + } + private static Date date(Document poll,String key) { + if(poll==null)return new Date(0);return poll.get(key) instanceof Date value?value:null; + } + private static Document query(Snapshot snapshot,RemoteReviewBinding binding,List timing) { + var clauses=new ArrayList(timing); + clauses.add(new Document("$eq",List.of(new Document("$size",new Document("$filter",new Document("input","$versions").append("as","v") + .append("cond",new Document("$eq",List.of("$$v._id",literal(binding.versionId())))))),1))); + return new Document(snapshot.project()).append("versions",snapshot.version()).append("$expr",new Document("$and",clauses)); + } + private static List patch(Snapshot snapshot,Document scanFields) { + var updated=new Document("$mergeObjects",List.of("$$v",new Document("scanResult",new Document("$mergeObjects",List.of("$$v.scanResult",scanFields))))); + return List.of(new Document("$set",new Document("versions",new Document("$map",new Document("input","$versions").append("as","v") + .append("in",new Document("$cond",List.of(new Document("$eq",List.of("$$v",literal(snapshot.version()))),updated,"$$v"))))))); + } + private static Document literal(Object value){return new Document("$literal",value);} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java new file mode 100644 index 000000000..a8ba292ff --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java @@ -0,0 +1,9 @@ +package net.modtale.service.security.scan; + +public final class RemoteReviewScheduler extends BoundedReviewScheduler { + public RemoteReviewScheduler(RemoteReviewDiscovery discovery,RemoteReviewBootstrap bootstrap,Settings settings) { + super("remote-review-poll",(cursor,limit)->{var page=discovery.page(cursor,limit);return new Page<>(page.candidates(),page.next());}, + (candidate,running)->bootstrap.advance(candidate.projectId(),candidate.versionId(),candidate.attempt(),candidate.requestId(),running).state(),settings); + java.util.Objects.requireNonNull(discovery);java.util.Objects.requireNonNull(bootstrap); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java new file mode 100644 index 000000000..cfcea3671 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java @@ -0,0 +1,21 @@ +package net.modtale.service.security.scan; + +import org.springframework.context.annotation.*; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +@Configuration +@ConditionalOnProperty(name="app.warden.jobs.scheduler.enabled",havingValue="true") +public class RemoteReviewSchedulerConfig { + @Bean(destroyMethod="close") + RemoteReviewScheduler remoteReviewScheduler(RemoteReviewDiscovery discovery,ObjectProvider bootstrap, + @Value("${app.warden.jobs.enabled:false}") boolean enabled, + @Value("${app.warden.jobs.scheduler.workers:2}") int workers, + @Value("${app.warden.jobs.scheduler.page-size:16}") int pageSize, + @Value("${app.warden.jobs.scheduler.poll-millis:1000}") long poll, + @Value("${app.warden.jobs.scheduler.drain-millis:10000}") long drain) { + if(!enabled)throw new IllegalStateException("Remote scheduler requires remote jobs enabled"); + return new RemoteReviewScheduler(discovery,bootstrap.getObject(),new RemoteReviewScheduler.Settings(workers,pageSize,poll,drain)); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java new file mode 100644 index 000000000..a8917b92b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java @@ -0,0 +1,58 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.RemoteReviewBinding; +import net.modtale.service.storage.StorageService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import java.util.concurrent.Semaphore; + +@Service +@ConditionalOnProperty(name="app.warden.jobs.enabled",havingValue="true") +public final class RemoteReviewStep { + public record Outcome(String state,String remoteState) {} + private final RemoteReviewPollStore polls; + private final RemoteReviewClient client; + private final StorageService storage; + private final ScanCompletionService completion; + private final Semaphore slots=new Semaphore(2); + public RemoteReviewStep(RemoteReviewPollStore polls,RemoteReviewClient client,StorageService storage,ScanCompletionService completion) { + this.polls=polls;this.client=client;this.storage=storage;this.completion=completion; + } + public Outcome advance(RemoteReviewBinding binding) {return advance(binding,()->true);} + public Outcome advance(RemoteReviewBinding binding,java.util.function.BooleanSupplier running) { + if(!running.getAsBoolean() || Thread.currentThread().isInterrupted())return new Outcome("SHUTDOWN",null); + if(binding.origin()==null)return new Outcome("UNVERIFIED_ORIGIN",null); + if(!slots.tryAcquire())return new Outcome("BUSY",null); + RemoteReviewPollStore.Claim claim=null; + try { + claim=polls.claim(binding,120000);if(claim==null)return new Outcome("NO_WORK",null); + var acquired=claim; + var status=client.submitOrFind(binding,()->storage.downloadBounded(binding.filePath(),100*1024*1024),()->running.getAsBoolean() && !Thread.currentThread().isInterrupted() && polls.isCurrent(acquired)); + if(!running.getAsBoolean() || Thread.currentThread().isInterrupted())return new Outcome("SHUTDOWN",null); + if(binding.jobId()==null) { + claim=polls.attachJob(claim,status.jobId());if(claim==null)return new Outcome("SUPERSEDED",null); + } + if("COMPLETED".equals(status.state())) { + if(!polls.isCurrent(claim))return new Outcome("SUPERSEDED",null); + var result=client.result(claim.binding()); + if(!running.getAsBoolean() || Thread.currentThread().isInterrupted())return new Outcome("SHUTDOWN",null); + boolean applied=completion.handleRemoteCompletedScan(claim,result); + return new Outcome(applied?"APPLIED":"SUPERSEDED",applied?"COMPLETED":null); + } + if(java.util.Set.of("CANCELLED","EXPIRED","HELD").contains(status.state())) { + boolean saved=polls.finishUnavailable(claim,status); + return new Outcome(saved?"UNAVAILABLE":"SUPERSEDED",saved?status.state():null); + } + boolean saved=polls.recordStatusAndRelease(claim,status,10000); + return new Outcome(saved?"RECORDED":"SUPERSEDED",saved?status.state():null); + } catch(RemoteReviewClient.Superseded stale) {return new Outcome("SUPERSEDED",null);} + catch(RuntimeException unavailable) { + if(claim!=null && unavailable instanceof RemoteReviewClient.Unavailable conflict && conflict.status()==409) { + try {return new Outcome(polls.finishContextConflict(claim)?"UNAVAILABLE":"SUPERSEDED",null);} + catch(RuntimeException unknown){return new Outcome("UNKNOWN",null);} + } + if(claim!=null)try {if(polls.release(claim,30000))return new Outcome("RETRY",null);}catch(RuntimeException unknown) { /* Ownership expires in the database. */ } + return new Outcome("UNKNOWN",null); + } finally {slots.release();} + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java index 3cc0ba595..a0c5caf1e 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java @@ -20,6 +20,7 @@ public class ScanCompletionService { private static final Logger logger = LoggerFactory.getLogger(ScanCompletionService.class); + private final WardenClientService warden; private final ProjectRepository projectRepository; private final ProjectService projectService; private final ProjectNotificationService projectNotificationService; @@ -37,8 +38,10 @@ public ScanCompletionService( SecurityIssueAnalysisService securityIssueAnalysisService, ScanRoutingService scanRoutingService, ScanPersistenceService scanPersistenceService, - ProjectVersionAccessService projectVersionAccessService + ProjectVersionAccessService projectVersionAccessService, + WardenClientService warden ) { + this.warden = warden; this.projectRepository = projectRepository; this.projectService = projectService; this.projectNotificationService = projectNotificationService; @@ -56,34 +59,118 @@ public void handleCompletedScan( boolean isManualRescan, ScanResult scanResult ) { + handleCompletedScan(projectId, versionId, expectedAttempt, isManualRescan, scanResult, null); + } + public void handleCompletedScan(String projectId, String versionId, int expectedAttempt, boolean isManualRescan, + ScanResult scanResult, String requestId) { + complete(projectId,versionId,expectedAttempt,isManualRescan,scanResult,requestId,null); + } + public boolean handleRemoteCompletedScan(RemoteReviewPollStore.Claim claim,ScanResult result) { + var binding=claim.binding(); + if(binding.origin()==null)return false; + if(result==null || !binding.requestId().equals(result.getScanRequestId()))return false; + return complete(binding.projectId(),binding.versionId(),binding.attempt(),binding.manualRescan(),result,binding.requestId(),claim); + } + private boolean complete(String projectId,String versionId,int expectedAttempt,boolean isManualRescan, + ScanResult scanResult,String requestId,RemoteReviewPollStore.Claim remote) { + scanResult.setScanRequestId(requestId); securityIssueAnalysisService.normalizeScanResult(scanResult); scanResult.setScanAttempt(expectedAttempt); Project project = projectRepository.findById(projectId).orElse(null); if (project == null) { logger.warn("Scan completed but project no longer exists project={} version={} attempt={}", projectId, versionId, expectedAttempt); - return; + return false; } ProjectVersion targetVersion = projectVersionAccessService.findById(project, versionId); if (targetVersion == null) { logger.warn("Scan completed but version no longer exists project={} version={} attempt={}", projectId, versionId, expectedAttempt); - return; + return false; } + ScanResult.RemoteReviewPoll remotePoll=null; + if(remote!=null) { + var current=targetVersion.getScanResult(); + if(current==null || !remote.binding().equals(current.getRemoteReview()) || !"REMOTE_REVIEW".equals(current.getScanState()))return false; + remotePoll=current.getRemotePoll(); + if(!ScanPersistenceService.remoteResultMatches(remote,remotePoll,scanResult,targetVersion))return false; + } + + if (scanResult.getSecurityEvidence() == null || !java.util.Objects.equals(targetVersion.getHash(), + scanResult.getSecurityEvidence().artifactSha256())) { + scanResult.setArtifactVerified(false); + } SecurityIssueAnalysisService.BaselineIndex baselines = securityIssueAnalysisService.collectApprovedIssueBaselines(project, versionId); SecurityIssueAnalysisService.ClassificationStats classification = securityIssueAnalysisService.annotateAgainstBaselines(scanResult, baselines); + new ArtifactReviewReuseService().annotate(project, versionId, scanResult); + boolean reuseAnnotated = scanResult.getReusedReviewVersion() != null; ScanRoutingService.RoutingDecision routingDecision = scanRoutingService.decideRouting(scanResult, classification, isManualRescan); + if(remote==null)scanResult.setReviewedContextSha256(ArtifactReviewContext.automaticallyReviewableFingerprint(targetVersion)); + if(remote!=null && routingDecision.action()==ScanRoutingService.RoutingAction.DEFER) + routingDecision=new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW,0); + if (scanResult.getReviewedContextSha256() == null) { + scanResult.setReusedReviewVersion(null); + scanResult.setReusedReviewApprovedAt(0); + if (!"BLOCK".equals(scanResult.getVerdict()) && scanResult.getStatus() != ScanStatus.INFECTED) { + scanResult.setVerdict("REVIEW"); + scanResult.setStatus(ScanStatus.SUSPICIOUS); + } + routingDecision = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, 0); + var notes = new java.util.ArrayList<>(scanResult.getReviewerNotes() == null + ? java.util.List.of() : scanResult.getReviewerNotes()); + notes.add("Separate files or unresolved dependency context require additional review before publishing."); + scanResult.setReviewerNotes(notes); + } + + if ((routingDecision.action() == ScanRoutingService.RoutingAction.SCHEDULE + || routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW) + && !java.util.Objects.equals(scanResult.getSecurityEvidence().policyVersion(), warden.currentPolicyVersion())) { + routingDecision = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, 0); + scanResult.setVerdict("REVIEW"); + scanResult.setStatus(ScanStatus.SUSPICIOUS); + scanResult.setReusedReviewVersion(null); + var notes = new java.util.ArrayList<>(scanResult.getReviewerNotes() == null + ? java.util.List.of() : scanResult.getReviewerNotes()); + notes.add("The current inspection policy could not validate this result. A fresh review is required."); + scanResult.setReviewerNotes(notes); + } + if (targetVersion.getFindingReviewHead() != null || targetVersion.getReplacementSecurityHold()!=null) { + scanResult.setReusedReviewVersion(null); + scanResult.setReusedReviewApprovedAt(0); + if (!"BLOCK".equals(scanResult.getVerdict()) && scanResult.getStatus() != ScanStatus.INFECTED) { + scanResult.setVerdict(targetVersion.getReplacementSecurityHold()!=null?"BLOCK":"REVIEW"); + scanResult.setStatus(ScanStatus.SUSPICIOUS); + } + routingDecision = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, 0); + var notes = new java.util.ArrayList<>(scanResult.getReviewerNotes()==null?java.util.List.of():scanResult.getReviewerNotes()); + notes.add(targetVersion.getReplacementSecurityHold()!=null + ? "A retained security block from an earlier review must be resolved before publication." + : "Recorded finding decisions require a current moderator review before publication."); + scanResult.setReviewerNotes(notes); + } + if (reuseAnnotated && scanResult.getReusedReviewVersion() == null) { + scanResult.setReusedReviewApprovedAt(0); + scanResult.setReusedReviewOrigins(null); + classification = securityIssueAnalysisService.annotateAgainstBaselines(scanResult, baselines); + } boolean approvedImmediately = routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW; boolean notifyFlagged = routingDecision.action() == ScanRoutingService.RoutingAction.REQUIRE_REVIEW; - if (!scanPersistenceService.applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision)) { + if (approvedImmediately) { + targetVersion.setScanResult(scanResult); + targetVersion.setSecurityApprovalProjectId(project.getId()); + securityIssueAnalysisService.markIssuesAcceptedForApprovedVersion(targetVersion); + } + if (!(remote!=null ? scanPersistenceService.applyRemoteScanOutcome(remote,remotePoll,scanResult,routingDecision,targetVersion) + : requestId == null ? scanPersistenceService.applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision, targetVersion) + : scanPersistenceService.applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision, targetVersion, requestId))) { logger.info("Scan result ignored because a newer attempt already exists project={} version={} attempt={}", projectId, versionId, expectedAttempt); - return; + return false; } Project refreshed = projectRepository.findById(projectId).orElse(null); @@ -107,22 +194,18 @@ public void handleCompletedScan( webhookService.triggerAdminFlaggedVersionWebhook(refreshed, refreshedVersion, scanResult); } - notifyProjectSubmissionIfReady(projectId, versionId); + if (routingDecision.action() != ScanRoutingService.RoutingAction.DEFER) notifyProjectSubmissionIfReady(projectId, versionId); if (approvedImmediately && refreshed != null) { ProjectVersion approvedVersion = projectVersionAccessService.findById(refreshed, versionId); if (approvedVersion != null) { - int pruned = securityIssueAnalysisService.pruneApprovedScanResults(refreshed); - if (pruned > 0) { - projectRepository.save(refreshed); - projectService.evictProjectCache(refreshed); - } if (refreshed.getStatus() == ProjectStatus.PUBLISHED) { projectNotificationService.notifyUpdates(refreshed, approvedVersion.getVersionNumber()); projectNotificationService.notifyDependents(refreshed, approvedVersion.getVersionNumber()); } } } + return true; } public void handleScanFailure( @@ -132,8 +215,13 @@ public void handleScanFailure( int expectedAttempt, RuntimeException exception ) { + handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, exception, null); + } + public void handleScanFailure(String projectId, String versionId, String originalFilename, int expectedAttempt, + RuntimeException exception, String requestId) { ScanResult degraded = scanRoutingService.buildPipelineErrorResult(exception, originalFilename, expectedAttempt); - boolean applied = scanPersistenceService.updateFailedScan(projectId, versionId, degraded, expectedAttempt); + boolean applied = requestId == null ? scanPersistenceService.updateFailedScan(projectId, versionId, degraded, expectedAttempt) + : scanPersistenceService.updateFailedScan(projectId, versionId, degraded, expectedAttempt, requestId); if (applied) { Project project = projectRepository.findById(projectId).orElse(null); @@ -151,10 +239,10 @@ public void handleTimedOutScan( String projectId, String versionId, String originalFilename, - int expectedAttempt + int expectedAttempt, ScanResult observed, long timeoutMillis ) { ScanResult timedOut = scanRoutingService.buildScanTimeoutResult(originalFilename, expectedAttempt); - boolean applied = scanPersistenceService.updateFailedScan(projectId, versionId, timedOut, expectedAttempt); + boolean applied = scanPersistenceService.updateTimedOutScan(projectId, versionId, timedOut, expectedAttempt, observed, timeoutMillis); if (!applied) { return; } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java index 8727b9dae..8490d8979 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java @@ -65,7 +65,8 @@ public ScanExecutionService( securityIssueAnalysisService, scanRoutingService, scanPersistenceService, - projectVersionAccessService + projectVersionAccessService, + wardenService ); this.wardenService = wardenService; this.storageService = storageService; @@ -88,18 +89,25 @@ public void enqueueBackgroundScan( boolean isManualRescan, int expectedAttempt ) { + enqueueBackgroundScan(projectId, versionId, filePath, originalFilename, isManualRescan, expectedAttempt, null); + } + public void enqueueBackgroundScan(String projectId, String versionId, String filePath, String originalFilename, + boolean isManualRescan, int expectedAttempt, String requestId) { + // The persisted request is the durable handoff; polling owns remote dispatch. + if (wardenService.remoteJobsEnabled()) return; taskExecutor.execute(() -> processBackgroundScan( projectId, versionId, filePath, originalFilename, isManualRescan, - expectedAttempt + expectedAttempt, requestId )); } @Scheduled(fixedDelayString = "${app.security.scan-recovery-check-ms:120000}") public void recoverStaleScanningVersions() { + if (wardenService.remoteJobsEnabled()) return; scanRecoveryService.recoverStaleScanningVersions(this::enqueueBackgroundScan); } @@ -118,9 +126,11 @@ private void processBackgroundScan( String filePath, String originalFilename, boolean isManualRescan, - int expectedAttempt + int expectedAttempt, String requestId ) { - if (!scanPersistenceService.markAttemptRunning(projectId, versionId, expectedAttempt)) { + if (wardenService.remoteJobsEnabled()) return; + if (!(requestId == null ? scanPersistenceService.markAttemptRunning(projectId, versionId, expectedAttempt) + : scanPersistenceService.markAttemptRunning(projectId, versionId, expectedAttempt, requestId))) { logger.info("Scan attempt skipped because state moved ahead project={} version={} attempt={}", projectId, versionId, expectedAttempt); return; } @@ -129,15 +139,12 @@ private void processBackgroundScan( try { byte[] fileBytes = storageService.download(filePath); - scanCompletionService.handleCompletedScan( - projectId, - versionId, - expectedAttempt, - isManualRescan, - wardenService.scanFile(fileBytes, originalFilename) - ); + var result = wardenService.scanFile(fileBytes, originalFilename); + if (requestId == null) scanCompletionService.handleCompletedScan(projectId, versionId, expectedAttempt, isManualRescan, result); + else scanCompletionService.handleCompletedScan(projectId, versionId, expectedAttempt, isManualRescan, result, requestId); } catch (RuntimeException e) { - scanCompletionService.handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, e); + if (requestId == null) scanCompletionService.handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, e); + else scanCompletionService.handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, e, requestId); } } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java index d92cfa2f9..cde95528d 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java @@ -32,6 +32,9 @@ public ScanPersistenceService( } public boolean markAttemptRunning(String projectId, String versionId, int attempt) { + return markAttemptRunning(projectId, versionId, attempt, null); + } + public boolean markAttemptRunning(String projectId, String versionId, int attempt, String requestId) { Update update = new Update() .set("versions.$.scanResult.status", ScanStatus.SCANNING) .set("versions.$.scanResult.scanState", "SCANNING") @@ -41,7 +44,7 @@ public boolean markAttemptRunning(String projectId, String versionId, int attemp .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) .set("updatedAt", LocalDateTime.now().toString()); - return mongoTemplate.updateFirst(buildVersionAttemptQuery(projectId, versionId, attempt), update, Project.class) + return mongoTemplate.updateFirst(bindRequest(buildVersionAttemptQuery(projectId, versionId, attempt, "QUEUED"), requestId), update, Project.class) .getModifiedCount() > 0; } @@ -50,32 +53,126 @@ public boolean applyScanOutcome( String versionId, int expectedAttempt, ScanResult scanResult, - ScanRoutingService.RoutingDecision routingDecision + ScanRoutingService.RoutingDecision routingDecision, + ProjectVersion reviewedVersion ) { + return applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision, reviewedVersion, null); + } + public boolean applyScanOutcome(String projectId, String versionId, int expectedAttempt, ScanResult scanResult, + ScanRoutingService.RoutingDecision routingDecision, ProjectVersion reviewedVersion, String requestId) { + return applyOutcome(projectId,versionId,expectedAttempt,scanResult,routingDecision,reviewedVersion,requestId,null,null); + } + public boolean applyRemoteScanOutcome(RemoteReviewPollStore.Claim claim,ScanResult.RemoteReviewPoll poll,ScanResult result, + ScanRoutingService.RoutingDecision routing,ProjectVersion version) { + if(!remoteResultMatches(claim,poll,result,version) || routing.action()==ScanRoutingService.RoutingAction.DEFER)return false; + result.setRemoteReview(claim.binding());result.setRemotePoll(null);result.setManualRescan(claim.binding().manualRescan()); + var binding=claim.binding(); + return applyOutcome(binding.projectId(),binding.versionId(),binding.attempt(),result,routing,version,binding.requestId(),claim,poll); + } + private boolean applyOutcome(String projectId,String versionId,int expectedAttempt,ScanResult scanResult, + ScanRoutingService.RoutingDecision routingDecision,ProjectVersion reviewedVersion,String requestId, + RemoteReviewPollStore.Claim remote,ScanResult.RemoteReviewPoll poll) { + scanResult.setScanRequestId(requestId); + if (routingDecision.action() == ScanRoutingService.RoutingAction.SCHEDULE + || routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW) { + String context = ArtifactReviewContext.automaticallyReviewableFingerprint(reviewedVersion); + if (reviewedVersion.getReplacementSecurityHold()!=null || reviewedVersion.getFindingReviewHead() != null || context == null || !context.equals(scanResult.getReviewedContextSha256()) + || !ArtifactClearancePolicy.cleared(scanResult)) return false; + } + if (routingDecision.action() == ScanRoutingService.RoutingAction.DEFER) { + scanResult.setStatus(ScanStatus.SCANNING); + scanResult.setScanState("WAITING_RETRY"); + scanResult.setScanTimestamp(System.currentTimeMillis()); + scanResult.setReviewerNotes(List.of("Inspection service capacity was temporarily unavailable. An automatic retry is pending; this version remains unpublished.")); + } Update update = new Update() .set("versions.$.scanResult", scanResult) .set("updatedAt", LocalDateTime.now().toString()); switch (routingDecision.action()) { case APPROVE_NOW -> { - update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.APPROVED); + update.set("versions.$.retainedRemoteReview", remote!=null ? remote.binding() : reviewedVersion.getRetainedRemoteReview()) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.APPROVED) + .set("versions.$.securityApprovalProjectId", reviewedVersion.getSecurityApprovalProjectId()) + .set("versions.$.approvedReviewOrigins", reviewedVersion.getApprovedReviewOrigins()) + .set("versions.$.approvedFindingReviewHead", null) + .set("versions.$.approvedSecurityEvidence", reviewedVersion.getApprovedSecurityEvidence()) + .set("versions.$.approvedSecurityContextSha256", reviewedVersion.getApprovedSecurityContextSha256()) + .set("versions.$.securityApprovedAt", reviewedVersion.getSecurityApprovedAt()) + .set("versions.$.approvedIssueBaselines", reviewedVersion.getApprovedIssueBaselines()) + .set("versions.$.scanResult", null); update.set("versions.$.scheduledPublishDate", null); } case SCHEDULE -> { update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.SCHEDULED); update.set("versions.$.scheduledPublishDate", LocalDateTime.now().plusMinutes(routingDecision.delayMinutes()).toString()); } + case DEFER -> { + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null); + } case REQUIRE_REVIEW -> { update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING); update.set("versions.$.scheduledPublishDate", null); } } - return mongoTemplate.updateFirst(buildVersionAttemptQuery(projectId, versionId, expectedAttempt), update, Project.class) - .getModifiedCount() > 0; + String expectedHash = scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().artifactSha256(); + Query target = bindRequest(buildVersionAttemptQueryBound(projectId, versionId, expectedAttempt, expectedHash, reviewedVersion, remote==null?"SCANNING":"REMOTE_REVIEW"), requestId); + boolean automatic = routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW + || routingDecision.action() == ScanRoutingService.RoutingAction.SCHEDULE; + boolean linked = scanResult.getReusedReviewVersion() != null; + boolean validOrigins = !automatic || ArtifactReviewLineage.bind(mongoTemplate, projectId, scanResult, target); + if (validOrigins && outcomeWrite(remoteTarget(target,remote,poll),update,remote)) return true; + if (automatic && linked) { + ArtifactReviewLineage.invalidate(scanResult); + var hold = new Update().set("versions.$.scanResult", scanResult) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null).set("updatedAt", LocalDateTime.now().toString()); + if (outcomeWrite(remoteTarget(bindRequest(buildVersionAttemptQueryBound(projectId, versionId, expectedAttempt, expectedHash, + reviewedVersion, remote==null?"SCANNING":"REMOTE_REVIEW"), requestId),remote,poll),hold,remote)) + projectRepository.findById(projectId).ifPresent(projectService::evictProjectCache); + } + // A held fallback must not make the caller announce an approval. + return false; + } + + static boolean remoteResultMatches(RemoteReviewPollStore.Claim claim,ScanResult.RemoteReviewPoll poll,ScanResult result,ProjectVersion version) { + if(claim==null || claim.binding().origin()==null || claim.binding().jobId()==null || poll==null || !claim.token().equals(poll.token()) || poll.leaseUntil()==null + || poll.nextPollAt()==null || result==null || version==null || result.getSecurityEvidence()==null)return false; + var b=claim.binding();var e=result.getSecurityEvidence(); + return b.versionId().equals(version.getId()) && b.artifactSha256().equals(version.getHash()) && b.filePath().equals(version.getFileUrl()) + && b.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(version)) + && b.contextSha256().equals(result.getReviewedContextSha256()) && b.requestId().equals(result.getScanRequestId()) + && b.artifactSha256().equals(e.artifactSha256()) && b.policyVersion().equals(e.policyVersion()); + } + private Query remoteTarget(Query target,RemoteReviewPollStore.Claim claim,ScanResult.RemoteReviewPoll poll) { + if(claim==null)return target; + var raw=target.getQueryObject();var version=raw.get("versions",org.bson.Document.class).get("$elemMatch",org.bson.Document.class); + version.put("scanResult.manualRescan",claim.binding().manualRescan()?true:new org.bson.Document("$in",java.util.Arrays.asList(false,null))); + var b=claim.binding(); + var bindingFields=new org.bson.Document("projectId",b.projectId()).append("versionId",b.versionId()).append("requestId",b.requestId()) + .append("attempt",b.attempt()).append("filePath",b.filePath()).append("artifactSha256",b.artifactSha256()).append("contextSha256",b.contextSha256()) + .append("policyVersion",b.policyVersion()).append("reviewConfigSha256",b.reviewConfigSha256()).append("origin",new org.bson.Document("deploymentId",b.origin().deploymentId()).append("callerScope",b.origin().callerScope())).append("jobId",b.jobId()).append("manualRescan",b.manualRescan()); + bindingFields.forEach((key,value)->version.put("scanResult.remoteReview."+key,value)); + version.put("scanResult.remotePoll",poll);version.put("fileUrl",b.filePath()); + var terms=new java.util.ArrayList();if(raw.containsKey("$expr"))terms.add(raw.get("$expr")); + terms.add(new org.bson.Document("$gt",List.of(poll.leaseUntil(),"$$NOW"))); + terms.add(new org.bson.Document("$eq",List.of(new org.bson.Document("$size",new org.bson.Document("$filter",new org.bson.Document("input","$versions") + .append("as","v").append("cond",new org.bson.Document("$eq",List.of("$$v._id",new org.bson.Document("$literal",claim.binding().versionId())))))),1))); + raw.put("$expr",new org.bson.Document("$and",terms)); + return new org.springframework.data.mongodb.core.query.BasicQuery(raw).collation(org.springframework.data.mongodb.core.query.Collation.of("simple")); + } + private boolean outcomeWrite(Query query,Update update,RemoteReviewPollStore.Claim remote) { + if(remote==null)return mongoTemplate.updateFirst(query,update,Project.class).getModifiedCount()>0; + var durable=new MongoTemplate(mongoTemplate.getMongoDatabaseFactory(),mongoTemplate.getConverter()); + durable.setWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)); + return durable.updateFirst(query,update,Project.class).getModifiedCount()>0; } - public boolean queueRetryAttempt(String projectId, String versionId, int currentAttempt, ScanResult queued) { + public boolean queueRetryAttempt(String projectId, String versionId, int currentAttempt, ScanResult queued, ScanResult observed, long timeoutMillis) { + Query target = recoveryQuery(projectId, versionId, currentAttempt, observed, timeoutMillis); + if (target == null) return false; Update retryUpdate = new Update() .set("versions.$.scanResult", queued) .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) @@ -83,20 +180,54 @@ public boolean queueRetryAttempt(String projectId, String versionId, int current .set("updatedAt", LocalDateTime.now().toString()); return mongoTemplate.updateFirst( - buildVersionAttemptQuery(projectId, versionId, currentAttempt), + target, retryUpdate, Project.class ).getModifiedCount() > 0; } public boolean updateFailedScan(String projectId, String versionId, ScanResult failed, int expectedAttempt) { + return updateFailedScan(projectId, versionId, failed, expectedAttempt, null); + } + public boolean updateFailedScan(String projectId, String versionId, ScanResult failed, int expectedAttempt, String requestId) { + failed.setScanRequestId(requestId); + return updateFailure(projectId, failed, bindRequest(buildVersionAttemptQuery(projectId, versionId, expectedAttempt, "SCANNING", "QUEUED", "WAITING_RETRY", null), requestId)); + } + public boolean updateTimedOutScan(String projectId, String versionId, ScanResult failed, int expectedAttempt, + ScanResult observed, long timeoutMillis) { + Query target = recoveryQuery(projectId, versionId, expectedAttempt, observed, timeoutMillis); + if (observed != null) failed.setScanRequestId(observed.getScanRequestId()); + return target != null && updateFailure(projectId, failed, target); + } + private Query recoveryQuery(String projectId, String versionId, int attempt, ScanResult observed, long timeoutMillis) { + if (observed == null || observed.getStatus() != ScanStatus.SCANNING || timeoutMillis <= 0 + || Math.max(1, observed.getScanAttempt()) != attempt) return null; + String state = observed.getScanState(); + if (state != null && !java.util.Set.of("SCANNING", "QUEUED", "WAITING_RETRY").contains(state)) return null; + long expires; + try { expires = Math.addExact(Math.max(0, observed.getScanTimestamp()), timeoutMillis); } + catch (ArithmeticException overflow) { return null; } + var raw = buildVersionAttemptQuery(projectId, versionId, attempt, state).getQueryObject(); + var version = raw.get("versions", org.bson.Document.class).get("$elemMatch", org.bson.Document.class); + version.put("scanResult.scanTimestamp", observed.getScanTimestamp() == 0 + ? new org.bson.Document("$in", java.util.Arrays.asList(0L, null)) : observed.getScanTimestamp()); + raw.put("$expr", new org.bson.Document("$lt", List.of(expires, new org.bson.Document("$toLong", "$$NOW")))); + return bindRequest(new org.springframework.data.mongodb.core.query.BasicQuery(raw), observed.getScanRequestId()); + } + private Query bindRequest(Query query, String requestId) { + var raw = query.getQueryObject(); + raw.get("versions", org.bson.Document.class).get("$elemMatch", org.bson.Document.class) + .put("scanResult.scanRequestId", requestId); + return new org.springframework.data.mongodb.core.query.BasicQuery(raw); + } + private boolean updateFailure(String projectId, ScanResult failed, Query target) { Update update = new Update() .set("versions.$.scanResult", failed) .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) .set("versions.$.scheduledPublishDate", null) .set("updatedAt", LocalDateTime.now().toString()); - boolean modified = mongoTemplate.updateFirst(buildVersionAttemptQuery(projectId, versionId, expectedAttempt), update, Project.class) + boolean modified = mongoTemplate.updateFirst(target, update, Project.class) .getModifiedCount() > 0; Project project = projectRepository.findById(projectId).orElse(null); @@ -112,6 +243,12 @@ public List findProjectsWithScanningVersions() { } private Query buildVersionAttemptQuery(String projectId, String versionId, int attempt) { + return buildVersionAttemptQuery(projectId, versionId, attempt, "SCANNING"); + } + private Query buildVersionAttemptQuery(String projectId, String versionId, int attempt, String... states) { + return buildVersionAttemptQueryBound(projectId, versionId, attempt, null, null, states); + } + private Query buildVersionAttemptQueryBound(String projectId, String versionId, int attempt, String expectedHash, ProjectVersion reviewedVersion, String... states) { Criteria attemptCriteria; if (attempt <= 1) { attemptCriteria = new Criteria().orOperator( @@ -123,12 +260,13 @@ private Query buildVersionAttemptQuery(String projectId, String versionId, int a attemptCriteria = Criteria.where("scanResult.scanAttempt").is(attempt); } - return new Query( - Criteria.where("_id").is(projectId) - .and("versions").elemMatch( - Criteria.where("_id").is(versionId) - .andOperator(attemptCriteria) - ) - ); + Criteria version = Criteria.where("_id").is(versionId) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) + .and("scanResult.status").is(ScanStatus.SCANNING) + .and("scanResult.scanState").in((Object[]) states) + .andOperator(attemptCriteria); + if (reviewedVersion != null) ArtifactReviewContext.bindSnapshot(version, reviewedVersion); + if (expectedHash != null) version.and("hash").is(expectedHash); + return new Query(Criteria.where("_id").is(projectId).and("versions").elemMatch(version)); } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java index 18204ceb9..0a374ae19 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java @@ -63,10 +63,13 @@ public void recoverStaleScanningVersions(BackgroundScanScheduler backgroundScanS int nextAttempt = currentAttempt + 1; ScanResult queued = scanRoutingService.createQueuedScanResult( nextAttempt, - "Previous scan attempt timed out and was re-queued automatically." + "WAITING_RETRY".equals(scanResult.getScanState()) + ? "Inspection capacity was temporarily unavailable; review was re-queued automatically." + : "Previous scan attempt timed out and was re-queued automatically." ); - if (scanPersistenceService.queueRetryAttempt(project.getId(), version.getId(), currentAttempt, queued)) { + queued.setManualRescan(scanResult.isManualRescan()); + if (scanPersistenceService.queueRetryAttempt(project.getId(), version.getId(), currentAttempt, queued, scanResult, timeoutMs)) { logger.warn( "Recovered stale scan by retrying project={} version={} previousAttempt={} nextAttempt={}", project.getId(), @@ -80,8 +83,8 @@ public void recoverStaleScanningVersions(BackgroundScanScheduler backgroundScanS version.getId(), version.getFileUrl(), extractOriginalFilename(version.getFileUrl()), - false, - nextAttempt + queued.isManualRescan(), + nextAttempt, queued.getScanRequestId() ); } } else { @@ -89,7 +92,7 @@ public void recoverStaleScanningVersions(BackgroundScanScheduler backgroundScanS project.getId(), version.getId(), extractOriginalFilename(version.getFileUrl()), - currentAttempt + currentAttempt, scanResult, timeoutMs ); } } @@ -117,7 +120,7 @@ void enqueue( String filePath, String originalFilename, boolean isManualRescan, - int expectedAttempt + int expectedAttempt, String requestId ); } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java index 1fd1964b0..586ccc1e3 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java @@ -7,7 +7,8 @@ import net.modtale.model.project.ProjectVersion; import net.modtale.model.project.ScanResult; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.VersionReviewPersistence; +import net.modtale.service.admin.review.VersionReviewSnapshot; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.query.ProjectService; import org.slf4j.Logger; @@ -19,7 +20,7 @@ public class ScanRequestService { private static final Logger logger = LoggerFactory.getLogger(ScanRequestService.class); - private final ProjectRepository projectRepository; + private final VersionReviewPersistence reviewPersistence; private final ProjectService projectService; private final ScanThrottleService scanThrottleService; private final ScanRoutingService scanRoutingService; @@ -27,14 +28,14 @@ public class ScanRequestService { private final ScanExecutionService scanExecutionService; public ScanRequestService( - ProjectRepository projectRepository, + VersionReviewPersistence reviewPersistence, ProjectService projectService, ScanThrottleService scanThrottleService, ScanRoutingService scanRoutingService, ProjectVersionAccessService projectVersionAccessService, ScanExecutionService scanExecutionService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.scanThrottleService = scanThrottleService; this.scanRoutingService = scanRoutingService; @@ -58,13 +59,15 @@ public void triggerRescan(String projectId, String versionId, User user) { throw new InvalidProjectRequestException("This version does not have an uploaded file to scan."); } + var snapshot = reviewPersistence.captureForRescan(projectId, versionId, VersionReviewSnapshot.rescanToken(version)); int attempt = scanRoutingService.nextScanAttempt(version.getScanResult()); ScanResult pending = scanRoutingService.createQueuedScanResult(attempt, "Manual rescan requested."); + pending.setManualRescan(true); + if (!reviewPersistence.queueRescan(snapshot, pending)) throw VersionReviewPersistence.conflict(); version.setScanResult(pending); version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); version.setScheduledPublishDate(null); - projectRepository.save(project); projectService.evictProjectCache(project); String originalFilename = scanExecutionService.extractOriginalFilename(version.getFileUrl()); @@ -76,7 +79,7 @@ public void triggerRescan(String projectId, String versionId, User user) { version.getFileUrl(), originalFilename, true, - attempt + attempt, pending.getScanRequestId() ); } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java index 348afe284..d504ac171 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java @@ -20,6 +20,7 @@ public ScanRoutingService(AppSecurityProperties securityProperties) { public ScanResult createQueuedScanResult(int attempt, String note) { ScanResult pending = new ScanResult(); + pending.setScanRequestId(java.util.UUID.randomUUID().toString()); pending.setStatus(ScanStatus.SCANNING); pending.setScanState("QUEUED"); pending.setRiskScore(0); @@ -47,46 +48,30 @@ public RoutingDecision decideRouting( SecurityIssueAnalysisService.ClassificationStats classification, boolean isManualRescan ) { - String verdict = scanResult.getVerdict() == null ? "" : scanResult.getVerdict().toUpperCase(); - ScanStatus status = scanResult.getStatus(); - - boolean block = "BLOCK".equals(verdict) || status == ScanStatus.INFECTED; - boolean explicitReview = "REVIEW".equals(verdict) - || status == ScanStatus.SUSPICIOUS - || status == ScanStatus.FLAGGED - || "UPSTREAM_UNAVAILABLE".equalsIgnoreCase(scanResult.getScanState()) - || hasRecoverableScannerErrors(scanResult); - - boolean knownOnly = classification.knownOnly(); - boolean hasEscalation = classification.escalatedIssueCount() > 0; - - if (isManualRescan) { - if (block || (explicitReview && (!knownOnly || hasEscalation))) { - return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); - } - return new RoutingDecision(RoutingAction.APPROVE_NOW, 0); - } - - if (block) { + if (!hasCompleteEvidence(scanResult) || "NEW_SECURITY_EVIDENCE".equals(scanResult.getSecurityEvidence().reviewState())) return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); + String verdict = scanResult.getVerdict(); + if ("BLOCK".equals(verdict) || scanResult.getStatus() == ScanStatus.INFECTED) { return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); } - - if (explicitReview) { - if (knownOnly && !hasEscalation && !"UPSTREAM_UNAVAILABLE".equalsIgnoreCase(scanResult.getScanState())) { - return new RoutingDecision( - RoutingAction.SCHEDULE, - randomDelay(securityProperties.knownRiskDelayMinutesMin(), securityProperties.knownRiskDelayMinutesMax()) - ); - } + boolean cleared = "AUTO_APPROVE".equals(verdict) && scanResult.getStatus() == ScanStatus.CLEAN + && scanResult.getSecurityEvidence().clearanceGranted(); + boolean reused = scanResult.getReusedReviewVersion() != null; + if (!cleared && !reused) { + if (java.util.Set.of("RATE_LIMITED", "TIMEOUT", "UPSTREAM_ERROR", "INTERRUPTED") + .contains(scanResult.getSecurityEvidence().reviewState() == null ? "" : scanResult.getSecurityEvidence().reviewState()) + && Math.max(1, scanResult.getScanAttempt()) <= Math.max(1, scanMaxRetries())) + return new RoutingDecision(RoutingAction.DEFER, 0); return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); } + if (isManualRescan) return new RoutingDecision(RoutingAction.APPROVE_NOW, 0); + long delay = reused + ? randomDelay(securityProperties.knownRiskDelayMinutesMin(), securityProperties.knownRiskDelayMinutesMax()) + : randomDelay(securityProperties.autoApproveDelayMinutesMin(), securityProperties.autoApproveDelayMinutesMax()); + return new RoutingDecision(RoutingAction.SCHEDULE, Math.max(holdDelayFromWarden(scanResult), delay)); + } - long holdDelayMinutes = holdDelayFromWarden(scanResult); - long randomizedDelay = randomDelay( - securityProperties.autoApproveDelayMinutesMin(), - securityProperties.autoApproveDelayMinutesMax() - ); - return new RoutingDecision(RoutingAction.SCHEDULE, Math.max(holdDelayMinutes, randomizedDelay)); + public boolean hasCompleteEvidence(ScanResult result) { + return ArtifactClearancePolicy.complete(result); } public long scanTimeoutMillis() { @@ -184,6 +169,7 @@ private long randomDelay(long minMinutes, long maxMinutes) { public enum RoutingAction { REQUIRE_REVIEW, + DEFER, SCHEDULE, APPROVE_NOW } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanService.java index 506568968..36c67c057 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanService.java @@ -47,4 +47,9 @@ public void enqueueBackgroundScan( expectedAttempt ); } + public void enqueueBackgroundScan(String projectId, String versionId, String filePath, String originalFilename, + boolean isManualRescan, int expectedAttempt, String requestId) { + scanExecutionService.enqueueBackgroundScan(projectId, versionId, filePath, originalFilename, isManualRescan, expectedAttempt, requestId); + } + } diff --git a/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java b/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java index cb54f89bd..5879959b9 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java @@ -9,6 +9,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ByteArrayResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Service; @@ -22,16 +24,43 @@ public class WardenClientService { private final WebClient webClient; private final AppWardenProperties wardenProperties; - public WardenClientService( - AppWardenProperties wardenProperties) { + private final boolean remoteJobsEnabled; + + public WardenClientService(AppWardenProperties wardenProperties) { + this(wardenProperties, false); + } + + @Autowired + public WardenClientService(AppWardenProperties wardenProperties, + @Value("${app.warden.jobs.enabled:false}") boolean remoteJobsEnabled) { + this.remoteJobsEnabled = remoteJobsEnabled; this.wardenProperties = wardenProperties; this.webClient = WebClient.builder() .baseUrl(wardenProperties.url()) + .codecs(config -> config.defaultCodecs().maxInMemorySize(32 * 1024 * 1024)) .defaultHeader("X-Warden-Api-Key", wardenProperties.apiKey()) .build(); } + public boolean remoteJobsEnabled() { return remoteJobsEnabled; } + + public record PolicyResponse(String policyVersion) {} + + public String currentPolicyVersion() { + if (!wardenProperties.enabled()) return null; + try { + var response = webClient.get().uri("/api/v1/policy").retrieve() + .bodyToMono(PolicyResponse.class).timeout(Duration.ofSeconds(10)).block(); + String policy = response == null ? null : response.policyVersion(); + return policy != null && policy.matches("warden-3\\.0\\.0:[0-9a-f]{64}") ? policy : null; + } catch (RuntimeException unavailable) { + logger.warn("Current scanner policy is unavailable; automatic publication is deferred"); + return null; + } + } + public ScanResult scanFile(byte[] fileBytes, String filename) { + if (remoteJobsEnabled) throw new IllegalStateException("Synchronous scans are disabled in remote jobs mode"); if (!wardenProperties.enabled()) { logger.error("Warden scanner is DISABLED. Falling back to manual-review degraded result for file: {}", filename); return buildDegradedResult(filename, new IllegalStateException("Warden scanner disabled")); @@ -59,6 +88,11 @@ public String getFilename() { .block(); if (response != null) { + var evidence = response.getSecurityEvidence(); + String expectedDigest = java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(fileBytes)); + response.setArtifactVerified(evidence != null && evidence.complete() + && expectedDigest.equals(evidence.artifactSha256())); return response; } throw new IllegalStateException("Warden returned empty response body"); @@ -75,6 +109,34 @@ public String getFilename() { return buildDegradedResult(filename, lastError); } + public record InspectionResponse(String artifactSha256, List paths, String content, String format, java.util.Map entryHashes, String policyVersion) {} + + public InspectionResponse inspectFile(byte[] bytes, String filename, String path) { + if (!wardenProperties.enabled()) throw new IllegalStateException("Artifact inspection is unavailable"); + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("file", new ByteArrayResource(bytes) { + @Override public String getFilename() { return filename; } + }); + if (path != null) builder.part("path", path); + return webClient.post().uri("/api/v1/inspect").contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())).retrieve() + .bodyToMono(InspectionResponse.class).timeout(Duration.ofSeconds(90)).block(); + } + + public record InspectionWindow(String artifactSha256, String path, String entrySha256, String policyVersion, + String representationSha256, String format, int start, int end, int totalCharacters, int firstLine, + boolean lineMatched, boolean representationComplete, String content, List gaps) {} + public InspectionWindow inspectWindow(byte[] bytes, String path, int offset, int characters, int sourceLine) { + if (!wardenProperties.enabled()) throw new IllegalStateException("Artifact inspection is unavailable"); + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("file", new ByteArrayResource(bytes) { @Override public String getFilename() { return "artifact.zip"; } }); + builder.part("path", path); builder.part("offset", Integer.toString(offset)); + builder.part("characters", Integer.toString(characters)); builder.part("sourceLine", Integer.toString(sourceLine)); + return webClient.post().uri("/api/v1/inspect-window").contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())).retrieve() + .bodyToMono(InspectionWindow.class).timeout(Duration.ofSeconds(90)).block(); + } + private void backoff(int attempt) { long delayMs = Math.min(3000L, 400L * attempt); try { diff --git a/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java b/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java index 2c6053595..e238f266f 100644 --- a/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java +++ b/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java @@ -9,38 +9,36 @@ import net.modtale.model.project.Comment; import net.modtale.model.project.Project; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; -import net.modtale.service.analytics.ScoringService; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; final class ProjectSocialService { + private final ProjectReviewPersistence reviewPersistence; private final FavoritePersistence favorites; - private final ProjectRepository projectRepository; private final UserRepository userRepository; private final ProjectService projectService; private final NotificationService notificationService; private final SanitizationService sanitizer; - private final ScoringService scoringService; ProjectSocialService( - ProjectRepository projectRepository, + FavoritePersistence favorites, UserRepository userRepository, ProjectService projectService, NotificationService notificationService, SanitizationService sanitizer, - ScoringService scoringService, FavoritePersistence favorites + ProjectReviewPersistence reviewPersistence ) { + this.reviewPersistence = reviewPersistence; this.favorites = favorites; - this.projectRepository = projectRepository; this.userRepository = userRepository; this.projectService = projectService; this.notificationService = notificationService; this.sanitizer = sanitizer; - this.scoringService = scoringService; } void toggleFavorite(String projectId, String userId) { @@ -50,7 +48,9 @@ void toggleFavorite(String projectId, String userId) { } void addComment(String projectId, String userId, String content) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); if (!project.isAllowComments()) { throw new ForbiddenOperationException("Comments are disabled for this project."); } @@ -60,7 +60,7 @@ void addComment(String projectId, String userId, String content) { project.setComments(new ArrayList<>()); } project.getComments().add(0, new Comment(user.getId(), sanitizer.sanitizePlainText(content))); - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); if (project.getAuthorId() != null && !project.getAuthorId().equals(userId)) { @@ -78,7 +78,9 @@ void addComment(String projectId, String userId, String content) { } void editComment(String projectId, String commentId, String userId, String newContent) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); Comment comment = project.getComments().stream() .filter(candidate -> candidate.getId().equals(commentId)) @@ -90,12 +92,14 @@ void editComment(String projectId, String commentId, String userId, String newCo comment.setContent(sanitizer.sanitizePlainText(newContent)); comment.setUpdatedAt(LocalDateTime.now().toString()); - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } void voteComment(String projectId, String commentId, String userId, boolean upvote) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); if (project.getComments() == null) { throw new ResourceNotFoundException("Project not found."); } @@ -119,12 +123,14 @@ void voteComment(String projectId, String commentId, String userId, boolean upvo comment.getUpvotes().remove(userId); } - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } void setCommentPinned(String projectId, String commentId, boolean pinned) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); if (project.getComments() == null) { throw new ResourceNotFoundException("Comment not found."); } @@ -133,7 +139,7 @@ void setCommentPinned(String projectId, String commentId, boolean pinned) { .findFirst() .orElseThrow(() -> new ResourceNotFoundException("Comment not found.")); comment.setPinned(pinned); - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } diff --git a/backend/src/main/java/net/modtale/service/social/SocialService.java b/backend/src/main/java/net/modtale/service/social/SocialService.java index ba2086f1c..ab0bfd746 100644 --- a/backend/src/main/java/net/modtale/service/social/SocialService.java +++ b/backend/src/main/java/net/modtale/service/social/SocialService.java @@ -2,9 +2,8 @@ import java.util.List; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; -import net.modtale.service.analytics.ScoringService; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; @@ -18,21 +17,21 @@ public class SocialService { private final UserFollowService userFollowService; public SocialService( - ProjectRepository projectRepository, + FavoritePersistence favorites, UserRepository userRepository, ProjectService projectService, NotificationService notificationService, SanitizationService sanitizer, MongoTemplate mongoTemplate, - ScoringService scoringService, FavoritePersistence favorites + ProjectReviewPersistence reviewPersistence ) { this.projectSocialService = new ProjectSocialService( - projectRepository, + favorites, userRepository, projectService, notificationService, sanitizer, - scoringService, favorites + reviewPersistence ); this.userFollowService = new UserFollowService(userRepository, notificationService, mongoTemplate); } diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadService.java b/backend/src/main/java/net/modtale/service/storage/DownloadService.java index 323185163..498913382 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadService.java @@ -6,7 +6,7 @@ import net.modtale.model.project.Project; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.project.query.ProjectService; import org.springframework.stereotype.Service; @@ -18,14 +18,14 @@ public class DownloadService { private final BundlePackagingService bundlePackagingService; public DownloadService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, StorageService storageService, AppLimitProperties limitProperties ) { DownloadArchiveSupport archiveSupport = new DownloadArchiveSupport(projectService, storageService); this.rateLimitService = new DownloadRateLimitService(limitProperties.modpackGenPerHour()); - this.modpackArchiveService = new ModpackArchiveService(projectRepository, archiveSupport); + this.modpackArchiveService = new ModpackArchiveService(reviewPersistence, archiveSupport); this.bundlePackagingService = new BundlePackagingService(archiveSupport); } diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java index 38ee5a065..5ac027ba7 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java @@ -25,7 +25,9 @@ import net.modtale.model.project.Project; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.admin.review.VersionReviewSnapshot; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,22 +41,24 @@ final class ModpackArchiveService { private static final String MANIFEST = "manifest.json"; private static final String LOCKFILE = "modtale.lock.json"; - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final DownloadArchiveSupport archiveSupport; - ModpackArchiveService(ProjectRepository projectRepository, DownloadArchiveSupport archiveSupport) { - this.projectRepository = projectRepository; + ModpackArchiveService(ProjectReviewPersistence reviewPersistence, DownloadArchiveSupport archiveSupport) { + this.reviewPersistence = reviewPersistence; this.archiveSupport = archiveSupport; } byte[] generateModpackZip(Project pack, ProjectVersion version) throws IOException { + String projectToken = ProjectReviewSnapshot.token(pack); + String versionToken = VersionReviewSnapshot.token(version); byte[] cachedArchive = downloadCachedArchive(pack, version); if (cachedArchive != null) { return cachedArchive; } byte[] zipBytes = buildArchive(pack, version); - cacheArchive(pack, version, zipBytes); + cacheArchive(pack, version, zipBytes, projectToken, versionToken); return zipBytes; } @@ -438,7 +442,7 @@ private String sha256(byte[] bytes) { } } - private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) { + private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes, String projectToken, String versionToken) { try { String fileName = (pack.getSlug() != null && !pack.getSlug().isEmpty() ? pack.getSlug() : pack.getId()) + "-" @@ -449,8 +453,9 @@ private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) "modpacks" ); + if (!reviewPersistence.cacheModpackArchive(pack.getId(), projectToken, version.getId(), versionToken, uploadPath)) + throw ProjectReviewSnapshot.conflict(); version.setFileUrl(uploadPath); - projectRepository.save(pack); } catch (StorageUploadException ex) { logger.warn("Generated modpack archive could not be cached for project={} version={}", pack.getId(), version.getVersionNumber(), ex); diff --git a/backend/src/main/java/net/modtale/service/storage/StorageService.java b/backend/src/main/java/net/modtale/service/storage/StorageService.java index cbd298d2e..144605ec3 100644 --- a/backend/src/main/java/net/modtale/service/storage/StorageService.java +++ b/backend/src/main/java/net/modtale/service/storage/StorageService.java @@ -208,6 +208,17 @@ public byte[] download(String fileName) { } } + public byte[] downloadBounded(String fileName,int maxBytes) { + if(maxBytes<1 || maxBytes>100*1024*1024)throw new IllegalArgumentException("Invalid download limit"); + try (ResponseInputStream response=s3Client.getObject(GetObjectRequest.builder().bucket(bucketName).key(fileName).build())) { + Long size=response.response().contentLength(); + if(size!=null && (size<0 || size>maxBytes)){response.abort();throw new IOException("Stored artifact exceeds download limit");} + byte[] bytes=response.readNBytes(maxBytes+1); + if(bytes.length>maxBytes || size!=null && size!=bytes.length){response.abort();throw new IOException("Stored artifact length is invalid");} + return bytes; + } catch(IOException | SdkException failure) {throw StorageDownloadException.from(failure,"Failed to download the requested artifact.");} + } + public java.util.Set findExistingKeys(java.util.Set requiredKeys) { java.util.Set found = new java.util.HashSet<>(); if (requiredKeys.isEmpty()) return found; diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 7f404585b..e3529b131 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -129,10 +129,16 @@ app.hytalemodding.wiki-url=https://wiki.hytalemodding.dev/api app.hytalemodding.wiki-key=${WIKI_API_KEY:} app.warden.url=${WARDEN_URL:http://localhost:8081} +app.warden.jobs.enabled=${WARDEN_REVIEW_JOBS_ENABLED:false} +app.warden.jobs.scheduler.enabled=${WARDEN_REVIEW_SCHEDULER_ENABLED:false} +app.warden.jobs.scheduler.workers=${WARDEN_REVIEW_SCHEDULER_WORKERS:2} +app.warden.jobs.scheduler.page-size=${WARDEN_REVIEW_SCHEDULER_PAGE_SIZE:16} +app.warden.jobs.scheduler.poll-millis=${WARDEN_REVIEW_SCHEDULER_POLL_MILLIS:1000} +app.warden.jobs.scheduler.drain-millis=${WARDEN_REVIEW_SCHEDULER_DRAIN_MILLIS:10000} app.warden.enabled=${WARDEN_ENABLED:true} app.warden.api-key=${WARDEN_API_KEY:} -app.warden.max-attempts=${WARDEN_MAX_ATTEMPTS:3} -app.warden.request-timeout-seconds=${WARDEN_REQUEST_TIMEOUT_SECONDS:75} +app.warden.max-attempts=${WARDEN_MAX_ATTEMPTS:1} +app.warden.request-timeout-seconds=${WARDEN_REQUEST_TIMEOUT_SECONDS:600} app.security.pre-auth-secret=${PRE_AUTH_SECRET:} app.security.pre-auth-expiry-seconds=600 diff --git a/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java new file mode 100644 index 000000000..fb0097f86 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java @@ -0,0 +1,50 @@ +package net.modtale.controller.admin; + +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.storage.StorageService; +import net.modtale.service.security.scan.WardenClientService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(ArtifactInspectionAuthorizationTest.Config.class) +class ArtifactInspectionAuthorizationTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ProjectService projects() {return mock(ProjectService.class);} + @Bean StorageService storage() {return mock(StorageService.class);} + @Bean WardenClientService inspector() {return mock(WardenClientService.class);} + @Bean(name="apiSecurity") PermissionEvaluator permissionEvaluator() {return new PermissionEvaluator();} + @Bean ArtifactInspectionController controller(ProjectService projects, StorageService storage, WardenClientService inspector) { + return new ArtifactInspectionController(projects,storage,inspector); + } + } + public static class PermissionEvaluator { + public boolean hasAdminPermission(String permission, Authentication auth) { + return auth!=null && auth.getAuthorities().stream().anyMatch(authority -> authority.getAuthority().equals(permission)); + } + } + @Autowired ArtifactInspectionController controller; + @Autowired ProjectService projects; + @Autowired StorageService storage; + @Autowired WardenClientService inspector; + @AfterEach void clearContext() {SecurityContextHolder.clearContext();} + @Test void comparisonAndSourceInspectionRequireReviewReadPermission() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("ordinary-user",null, + List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_DECIDE")))); + assertThrows(AccessDeniedException.class,()->controller.structure("project","version",null)); + assertThrows(AccessDeniedException.class,()->controller.file("project","version","manifest.json",null)); + assertThrows(AccessDeniedException.class,()->controller.window("project","version","manifest.json",0,32000,0,null,null)); + assertThrows(AccessDeniedException.class,()->controller.changes("project","version",null)); + verifyNoInteractions(projects,storage,inspector); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java new file mode 100644 index 000000000..c25cac328 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java @@ -0,0 +1,163 @@ +package net.modtale.controller.admin; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.WardenClientService; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ArtifactInspectionControllerTest { + private WardenClientService.InspectionWindow window(Fixture f, int start, String policy) { + return new WardenClientService.InspectionWindow(f.after.getHash(),"file.json","a".repeat(64),policy,"b".repeat(64), + "TEXT_RESOURCE",start,start+2,4,1,true,true,"{}",List.of()); + } + @Test void windowContinuationsBindRepresentationAndReviewState() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,0,"policy")); + var first=f.controller.window("project","2","file.json",0,2,0,null,token).getBody(); + assertEquals(64,first.identity().length()); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,2,"policy")); + assertEquals(2,f.controller.window("project","2","file.json",2,2,0,first.identity(),token).getBody().start()); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,2,"changed-policy")); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",2,2,0,first.identity(),token)).getStatusCode().value()); + doAnswer(invocation->{f.after.setFindingReviewHead("new-head");return window(f,0,"policy");}) + .when(f.inspector).inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,2,0,null,token)).getStatusCode().value()); + } + @Test void invalidWindowRequestsStopBeforeArtifactAccessAndMalformedRepliesAreRejected() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,2,0,null,"stale")).getStatusCode().value()); + assertEquals(400,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",2,2,0,null,token)).getStatusCode().value()); + assertEquals(400,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,32001,0,null,token)).getStatusCode().value()); + verifyNoInteractions(f.storage,f.inspector); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,1,"policy")); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,2,0,null,token)).getStatusCode().value()); + } + @Test void windowHttpRouteRequiresTheOpenedReviewAndReturnsBoundedJsonWithoutCaching() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,0,"policy")); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(f.controller).build(); + String route="/api/v1/admin/projects/project/versions/2/file-window"; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).param("path","file.json")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + verifyNoInteractions(f.storage,f.inspector); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).param("path","file.json").header("If-Match",token)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.content").value("{}")); + } + @Test void comparesFullCaseSensitiveNestedPathsAndRemovals() { + var before=Map.of("nested.jar!/A.class","a", "removed.class","b", "same.json","c"); + var after=Map.of("nested.jar!/A.class","changed", "nested.jar!/a.class","a", "same.json","c"); + var diff=ArtifactInspectionController.compare("snapshot","1.0",true,true,before,after); + assertEquals(1,diff.added()); assertEquals(1,diff.modified()); assertEquals(1,diff.removed()); assertEquals(1,diff.unchanged()); + assertTrue(diff.contextChanged()); + assertTrue(diff.files().contains(new ArtifactInspectionController.FileChange("nested.jar!/a.class","ADDED"))); + } + @Test void refusesStoredArtifactMismatchBeforeRequestingInspection() { + var projects=mock(ProjectService.class); var storage=mock(StorageService.class); var inspector=mock(WardenClientService.class); + var project=new Project();var version=new ProjectVersion();version.setVersionNumber("1.0");version.setHash("a".repeat(64)); + version.setFileUrl("stored.zip");project.setVersions(List.of(version)); + when(projects.getRawProjectById("project")).thenReturn(project); + when(storage.download("stored.zip")).thenReturn("different".getBytes()); + var controller=new ArtifactInspectionController(projects,storage,inspector); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.structure("project","1.0",net.modtale.service.admin.review.ProjectReviewSnapshot.token(project))).getStatusCode().value()); + verifyNoInteractions(inspector); + } + private static final class Fixture { + final ProjectService projects=mock(ProjectService.class); + final StorageService storage=mock(StorageService.class); + final WardenClientService inspector=mock(WardenClientService.class); + final Project project=new Project(); + final ProjectVersion before=new ProjectVersion(), after=new ProjectVersion(); + final byte[] bytes="fixture artifact".getBytes(java.nio.charset.StandardCharsets.UTF_8); + final ArtifactInspectionController controller=new ArtifactInspectionController(projects,storage,inspector); + final WardenClientService.InspectionResponse response; + Fixture() throws Exception { + String hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + before.setId("before");before.setVersionNumber("1");before.setHash(hash);before.setFileUrl("before.zip"); + before.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);before.setSecurityApprovedAt(1); + before.setApprovedSecurityContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(before)); + after.setId("after");after.setVersionNumber("2");after.setHash(hash);after.setFileUrl("after.zip"); + project.setId("project");project.setVersions(List.of(before,after)); + response=new WardenClientService.InspectionResponse(hash,List.of("file.json"),"{}","TEXT",Map.of("file.json","a".repeat(64)),"policy"); + when(projects.getRawProjectById("project")).thenReturn(project); + when(storage.download(anyString())).thenReturn(bytes); + when(inspector.inspectFile(any(),anyString(),nullable(String.class))).thenReturn(response); + } + } + @Test void comparisonRejectsReviewAndContextChangesDuringInspection() throws Exception { + for (int scenario=0;scenario<4;scenario++) { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project);final int change=scenario; + doAnswer(invocation->{ + switch(change) { + case 0 -> f.before.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + case 1 -> f.before.setFindingReviewHead("new-head"); + case 2 -> f.after.setGameVersions(List.of("changed-runtime")); + case 3 -> f.project.setVersions(List.of(f.after)); + } + return f.response; + }).when(f.inspector).inspectFile(any(),anyString(),nullable(String.class)); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.changes("project","2",token)).getStatusCode().value()); + } + } + @Test void fileAndStructureRejectChangesWhileArtifactIsInspected() throws Exception { + for(boolean content:new boolean[]{false,true}) { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + doAnswer(invocation->{f.after.setFindingReviewHead("new-review");return f.response;}) + .when(f.inspector).inspectFile(any(),anyString(),nullable(String.class)); + assertEquals(409,assertThrows(ResponseStatusException.class,()->{ + if(content)f.controller.file("project","2","file.json",token);else f.controller.structure("project","2",token); + }).getStatusCode().value()); + } + } + @Test void ambiguousVersionNamesNeverSelectAnArbitraryArtifact() throws Exception { + var f=new Fixture();f.before.setVersionNumber("2");String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.changes("project","2",token)).getStatusCode().value()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.structure("project","2",token)).getStatusCode().value()); + verifyNoInteractions(f.storage,f.inspector); + } + @Test void unchangedInspectionUsesOneCapturedSelectionAndIgnoresDownloadCounters() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + doAnswer(invocation->{f.after.setDownloadCount(2);return f.response;}) + .when(f.inspector).inspectFile(any(),anyString(),nullable(String.class)); + var result=f.controller.changes("project","2",token).getBody(); + assertEquals("1",result.baselineVersion());assertEquals(1,result.unchanged());assertTrue(result.contextComparable()); + verify(f.projects,times(2)).getRawProjectById("project"); + verify(f.storage).download("before.zip");verify(f.storage).download("after.zip"); + } + @Test void editedBaselineContextCannotBePresentedAsComparableApprovedContext() throws Exception { + var f=new Fixture();f.before.setGameVersions(List.of("edited-since-approval"));String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertFalse(f.controller.changes("project","2",token).getBody().contextComparable()); + } + + @Test void comparisonTokenBindsLaterFileAndStructureReadsBeforeAnyDownload() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertEquals(token,f.controller.changes("project","2",token).getBody().reviewToken()); + clearInvocations(f.storage,f.inspector); + f.before.setFindingReviewHead("revoked-after-comparison"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.file("project","1","file.json",token)).getStatusCode().value()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.structure("project","2",token)).getStatusCode().value()); + verifyNoInteractions(f.storage,f.inspector); + } + @Test void httpInspectionRequiresTheOpenedProjectTokenOnEveryRoute() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(f.controller).build(); + for(String route:List.of("changes","structure","file")) { + var path="/api/v1/admin/projects/project/versions/2/"+route; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(path).param("path","file.json")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(path).param("path","file.json").header("If-Match","stale")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + verifyNoInteractions(f.storage,f.inspector); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(path).param("path","file.json").header("If-Match",token)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")); + clearInvocations(f.storage,f.inspector); + } + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/DependencyInspectionAuthorizationTest.java b/backend/src/test/java/net/modtale/controller/admin/DependencyInspectionAuthorizationTest.java new file mode 100644 index 000000000..0999b6178 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/DependencyInspectionAuthorizationTest.java @@ -0,0 +1,50 @@ +package net.modtale.controller.admin; + +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.DependencyReviewSource; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(DependencyInspectionAuthorizationTest.Config.class) +class DependencyInspectionAuthorizationTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ProjectService projects(){return mock(ProjectService.class);} + @Bean DependencyReviewSource source(){return mock(DependencyReviewSource.class);} + @Bean(name="apiSecurity") PermissionEvaluator permissions(){return new PermissionEvaluator();} + @Bean DependencyInspectionController controller(ProjectService projects,DependencyReviewSource source){return new DependencyInspectionController(projects,()->source,mock(net.modtale.service.security.scan.DependencyArtifactVerifier.class));} + } + public static class PermissionEvaluator { + public boolean hasAdminPermission(String permission,Authentication auth) { + return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission)); + } + } + @Autowired DependencyInspectionController controller; + @Autowired ProjectService projects; + @Autowired DependencyReviewSource source; + @AfterEach void cleanup(){SecurityContextHolder.clearContext();reset(projects,source);} + @Test void unrelatedOrDecisionOnlyPermissionCannotReadDependencies() { + for(String permission:List.of("PROJECT_REVIEW_DECIDE","PROJECT_EDIT","ROLE_USER")) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user",null,List.of(new SimpleGrantedAuthority(permission)))); + assertThrows(AccessDeniedException.class,()->controller.inspect("p","v",null)); + assertThrows(AccessDeniedException.class,()->controller.verifyBytes("p","v","a".repeat(64),null)); + } + verifyNoInteractions(projects,source); + } + @Test void reviewReadPermissionEntersSnapshotValidation() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_READ")))); + assertEquals(404,assertThrows(ResponseStatusException.class,()->controller.inspect("p","v",null)).getStatusCode().value()); + verify(projects).getRawProjectById("p");verifyNoInteractions(source); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/DependencyInspectionControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/DependencyInspectionControllerTest.java new file mode 100644 index 000000000..a4d379cd1 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/DependencyInspectionControllerTest.java @@ -0,0 +1,116 @@ +package net.modtale.controller.admin; + +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.*; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.function.Supplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static net.modtale.service.security.scan.DependencyReviewGraph.*; + +class DependencyInspectionControllerTest { + final ProjectService projects=mock(ProjectService.class); + final DependencyReviewSource source=mock(DependencyReviewSource.class); + final Supplier sources=mock(Supplier.class); + final Project project=new Project();final ProjectVersion version=new ProjectVersion(); + final DependencyArtifactVerifier verifier=mock(DependencyArtifactVerifier.class); + final DependencyInspectionController controller=new DependencyInspectionController(projects,sources,verifier); + DependencyInspectionControllerTest() { + project.setId("p");version.setId("v");version.setVersionNumber("1");version.setHash("a".repeat(64));version.setFileUrl("files/v"); + project.setVersions(List.of(version));when(projects.getRawProjectById("p")).thenReturn(project);when(sources.get()).thenReturn(source); + when(source.readRoot("p","v")).thenAnswer(i->new Lookup(State.FOUND,snapshot())); + when(source.read(any())).thenAnswer(i->new Lookup(State.FOUND,snapshot())); + } + Snapshot snapshot(){return new Snapshot("p","v","1","files/v","a".repeat(64),ArtifactReviewContext.fingerprint(version),null,false,List.of());} + @Test void byteHttpRouteRequiresInventoryAndReturnsAnUncachedObservation() throws Exception { + String token=ProjectReviewSnapshot.token(project);String identity=controller.inspect("p","v",token).getBody().inventory().identity(); + when(verifier.verify(any())).thenReturn(new DependencyArtifactVerifier.Result(identity,List.of(),DependencyArtifactVerifier.State.BUSY,0)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + String route="/api/v1/admin/projects/p/version-ids/v/dependency-bytes"; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).header("If-Match",token)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest()); + verifyNoInteractions(verifier); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).header("If-Match",token).param("inventoryIdentity",identity)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.verification.state").value("BUSY")); + } + @Test void byteRequestsRequireBothTheOpenedReviewAndInventoryIdentityBeforeStorage() { + String token=ProjectReviewSnapshot.token(project); + assertEquals(400,assertThrows(ResponseStatusException.class,()->controller.verifyBytes("p","v","invalid",token)).getStatusCode().value()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.verifyBytes("p","v","a".repeat(64),null)).getStatusCode().value()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.verifyBytes("p","v","a".repeat(64),token)).getStatusCode().value()); + verifyNoInteractions(verifier); + } + @Test void byteOutcomesRemainExplicitAndAreRevalidatedWithoutGrantingApproval() { + String token=ProjectReviewSnapshot.token(project);String identity=controller.inspect("p","v",token).getBody().inventory().identity(); + for(var state:DependencyArtifactVerifier.State.values()) { + when(verifier.verify(any())).thenReturn(new DependencyArtifactVerifier.Result(identity,List.of(),state,0)); + var result=controller.verifyBytes("p","v",identity,token); + assertEquals("no-store",result.getHeaders().getCacheControl());assertEquals(state,result.getBody().verification().state()); + assertEquals(token,result.getBody().reviewToken()); + } + } + @Test void changedTransitiveArtifactAfterStorageDiscardsMatchedObservation() { + var dependency=new ProjectDependency("child",null,"1");version.setDependencies(List.of(dependency)); + var root=new Snapshot("p","v","1","files/v","a".repeat(64),ArtifactReviewContext.fingerprint(version),null,false, + List.of(new Dependency(dependency.getSource(),dependency.getDependencyType(),new Reference("child","1")))); + var child=new java.util.concurrent.atomic.AtomicReference<>(new Snapshot("child","child-v","1","files/child","b".repeat(64),"c".repeat(64),null,false,List.of())); + when(source.readRoot("p","v")).thenReturn(new Lookup(State.FOUND,root)); + when(source.read(any())).thenAnswer(i->new Lookup(State.FOUND,((Reference)i.getArgument(0)).projectId().equals("p")?root:child.get())); + String token=ProjectReviewSnapshot.token(project);String identity=controller.inspect("p","v",token).getBody().inventory().identity(); + when(verifier.verify(any())).thenAnswer(i->{ + child.set(new Snapshot("child","child-v","1","files/child","d".repeat(64),"c".repeat(64),null,false,List.of())); + return new DependencyArtifactVerifier.Result(identity,List.of(),DependencyArtifactVerifier.State.MATCHED,1); + }); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.verifyBytes("p","v",identity,token)).getStatusCode().value()); + } + @Test void rootReviewChangeDuringStorageDiscardsByteResult() { + String token=ProjectReviewSnapshot.token(project);String identity=controller.inspect("p","v",token).getBody().inventory().identity(); + when(verifier.verify(any())).thenAnswer(i->{version.setFindingReviewHead("changed");return new DependencyArtifactVerifier.Result(identity,List.of(),DependencyArtifactVerifier.State.MATCHED,1);}); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.verifyBytes("p","v",identity,token)).getStatusCode().value()); + } + @Test void missingAndStaleReviewTokensStopBeforeGraphAccess() { + for(String token:Arrays.asList(null,"stale"))assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.inspect("p","v",token)).getStatusCode().value()); + verifyNoInteractions(sources,source); + } + @Test void exactVersionIdsAndRootBindingPreventSelectingAnotherUpload() { + String token=ProjectReviewSnapshot.token(project); + assertEquals(404,assertThrows(ResponseStatusException.class,()->controller.inspect("p","1",token)).getStatusCode().value()); + when(source.readRoot("p","v")).thenReturn(new Lookup(State.FOUND,new Snapshot("p","v","1","files/v","b".repeat(64),ArtifactReviewContext.fingerprint(version),null,false,List.of()))); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.inspect("p","v",token)).getStatusCode().value()); + verify(source,never()).read(any()); + } + @Test void ambiguousAndUnavailableRootsDoNotProduceAnInventory() { + for(var state:List.of(State.AMBIGUOUS,State.MISSING,State.UNAVAILABLE)) { + when(source.readRoot("p","v")).thenReturn(new Lookup(state,null)); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.inspect("p","v",ProjectReviewSnapshot.token(project))).getStatusCode().value()); + } + } + @Test void rootReviewMutationDuringGraphTraversalRejectsTheResponse() { + String token=ProjectReviewSnapshot.token(project); + when(source.read(any())).thenAnswer(i->{version.setFindingReviewHead("changed");return new Lookup(State.FOUND,snapshot());}); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.inspect("p","v",token)).getStatusCode().value()); + } + @Test void dependencyReadFailureIsAnExplicitGapNotClearance() { + when(source.read(any())).thenReturn(new Lookup(State.UNAVAILABLE,null)); + var response=controller.inspect("p","v",ProjectReviewSnapshot.token(project)).getBody(); + assertFalse(response.artifactBytesVerified());assertNull(response.inventory().identity());assertFalse(response.inventory().gaps().isEmpty()); + } + @Test void httpRouteRequiresSnapshotAndReturnsUncachedDeclarationInventory() throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var route="/api/v1/admin/projects/p/version-ids/v/dependencies"; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + verifyNoInteractions(sources,source); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).header("If-Match",ProjectReviewSnapshot.token(project))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.artifactBytesVerified").value(false)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.inventory.root.versionId").value("v")); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java b/backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java new file mode 100644 index 000000000..97cddc626 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java @@ -0,0 +1,61 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.model.user.User; +import net.modtale.service.security.issue.FindingReviewService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(FindingReviewAuthorizationTest.Config.class) +class FindingReviewAuthorizationTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean FindingReviewService reviews() { return mock(FindingReviewService.class); } + @Bean AccountService accounts() { return mock(AccountService.class); } + @Bean(name="apiSecurity") Permissions permissions() { return new Permissions(); } + @Bean FindingReviewController controller(FindingReviewService reviews, AccountService accounts) { return new FindingReviewController(reviews, accounts); } + } + public static class Permissions { + public boolean hasAdminPermission(String permission, Authentication auth) { + return auth != null && auth.getAuthorities().stream().anyMatch(a -> permission.equals(a.getAuthority())); + } + } + @Autowired FindingReviewController controller; + @Autowired FindingReviewService reviews; + @Autowired AccountService accounts; + @BeforeEach void resetMocks() { reset(reviews, accounts); } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + private void permission(String permission) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", null, + List.of(new SimpleGrantedAuthority(permission)))); + } + @Test void readPermissionCannotWriteOrRevoke() { + permission("PROJECT_REVIEW_READ"); + assertThrows(AccessDeniedException.class, () -> controller.record("p", "v", "token", null)); + assertThrows(AccessDeniedException.class, () -> controller.revoke("p", "v", "decision", "token", null)); + verifyNoInteractions(reviews, accounts); + controller.history("p", "v", "token", 0); + verify(reviews).history("p", "v", "token", 0); + } + @Test void decisionPermissionUsesAuthenticatedActorAndSnapshot() { + permission("PROJECT_REVIEW_DECIDE"); + var actor = new User(); actor.setId("server-actor"); + when(accounts.requireCurrentUser(anyString())).thenReturn(actor); + var request = new FindingReviewService.Request(2, FindingReviewService.Disposition.REQUIRE_REVIEW, "Needs further investigation"); + controller.record("p", "v", "snapshot", request); + verify(reviews).record("p", "v", "snapshot", "server-actor", request); + controller.revoke("p", "v", "decision", "snapshot", new FindingReviewController.Revocation("Incorrect previous decision")); + verify(reviews).revoke("p", "v", "snapshot", "server-actor", "decision", "Incorrect previous decision"); + assertThrows(AccessDeniedException.class, () -> controller.history("p", "v", "snapshot", 0)); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java new file mode 100644 index 000000000..59c92640f --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java @@ -0,0 +1,83 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.admin.review.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ModerationQueuePageControllerTest.Config.class) +class ModerationQueuePageControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ModerationQueuePageReader reader(){return mock(ModerationQueuePageReader.class);} + @Bean ModerationQueuePageController controller(ModerationQueuePageReader reader){return new ModerationQueuePageController(reader);} + @Bean net.modtale.service.admin.review.ProjectReviewAdminService reviews(){return mock(net.modtale.service.admin.review.ProjectReviewAdminService.class);} + @Bean net.modtale.service.user.account.AccountService accounts(){return mock(net.modtale.service.user.account.AccountService.class);} + @Bean net.modtale.service.admin.project.ProjectAdminOperationsService operations(){return mock(net.modtale.service.admin.project.ProjectAdminOperationsService.class);} + @Bean ProjectManagementController legacy(net.modtale.service.user.account.AccountService accounts,net.modtale.service.admin.review.ProjectReviewAdminService reviews,net.modtale.service.admin.project.ProjectAdminOperationsService operations){return new ProjectManagementController(accounts,reviews,operations);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired ModerationQueuePageController controller; + @Autowired ModerationQueuePageReader reader; + @Autowired ProjectManagementController legacy; + @Autowired net.modtale.service.admin.review.ProjectReviewAdminService reviews; + @Autowired net.modtale.service.user.account.AccountService accounts; + @Autowired net.modtale.service.admin.project.ProjectAdminOperationsService operations; + @BeforeEach void setup(){reset(reader);auth("PROJECT_REVIEW_READ");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + void auth(String permission){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority(permission))));} + @Test void permissionIsCheckedBeforeCursorOrDatabaseAccess() { + auth("PROJECT_REVIEW_DECIDE");assertThrows(AccessDeniedException.class,()->controller.read("bad",25,ModerationQueuePageReader.Filter.ALL));verifyNoInteractions(reader); + } + @Test void httpUsesDefaultPageSizeAndReturnsContinuationEvenWhenAllRowsAreUnavailable()throws Exception { + var next=new ModerationQueuePageReader.Cursor("project",7); + when(reader.page(null,25,ModerationQueuePageReader.Filter.ALL)).thenReturn(new ModerationQueuePageReader.Page(List.of(),next,25)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/queue/page")).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.nextCursor").value(ModerationQueueCursor.encode(next))).andExpect(jsonPath("$.unavailableItems").value(25)) + .andExpect(jsonPath("$.order").value("PROJECT_VERSION")).andExpect(jsonPath("$.items").isEmpty()); + verify(reader).page(null,25,ModerationQueuePageReader.Filter.ALL); + } + @Test void invalidHttpParametersNeverReachTheDatabase()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(String limit:List.of("0","51","2147483648","no"))mvc.perform(get("/api/v1/admin/verification/queue/page").param("limit",limit)).andExpect(status().isBadRequest()); + mvc.perform(get("/api/v1/admin/verification/queue/page").param("cursor","invalid")).andExpect(status().isBadRequest());verifyNoInteractions(reader); + } + @Test void retiredEndpointReturnsExplicitMigrationWithoutReadingServices()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(legacy).build(); + mvc.perform(get("/api/v1/admin/verification/queue")).andExpect(status().isGone()) + .andExpect(header().string("Cache-Control","no-store")) + .andExpect(header().string("Link","; rel=\"successor-version\"")) + .andExpect(jsonPath("$.status").value(410)); + verifyNoInteractions(reader,reviews,accounts,operations); + } + @Test void retiredEndpointStillRequiresReviewReadPermission() { + auth("PROJECT_REVIEW_DECIDE");assertThrows(AccessDeniedException.class,()->legacy.getVerificationQueue()); + verifyNoInteractions(reader,reviews,accounts,operations); + } + @Test void filterMismatchAndUnknownFilterCannotReadDatabase()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var cursor=ModerationQueueCursor.encode(new ModerationQueuePageReader.Cursor("a",1,ModerationQueuePageReader.Filter.SECURITY)); + mvc.perform(get("/api/v1/admin/verification/queue/page").param("filter","OPERATIONS").param("cursor",cursor)).andExpect(status().isBadRequest()); + mvc.perform(get("/api/v1/admin/verification/queue/page").param("filter","UNKNOWN")).andExpect(status().isBadRequest());verifyNoInteractions(reader); + } + @Test void exactTypedCursorIsPassedToReader() { + var cursor=new ModerationQueuePageReader.Cursor(new org.bson.types.ObjectId(),8); + when(reader.page(cursor,1,ModerationQueuePageReader.Filter.ALL)).thenReturn(new ModerationQueuePageReader.Page(List.of(),null,0)); + var result=controller.read(ModerationQueueCursor.encode(cursor),1,ModerationQueuePageReader.Filter.ALL);assertNull(result.getBody().nextCursor());verify(reader).page(cursor,1,ModerationQueuePageReader.Filter.ALL); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java new file mode 100644 index 000000000..664f84b99 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java @@ -0,0 +1,48 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.security.issue.PriorFindingReasoningService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(PriorFindingReasoningControllerTest.Config.class) +class PriorFindingReasoningControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean PriorFindingReasoningService service(){return mock(PriorFindingReasoningService.class);} + @Bean PriorFindingReasoningController controller(PriorFindingReasoningService service){return new PriorFindingReasoningController(service);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired PriorFindingReasoningController controller; + @Autowired PriorFindingReasoningService service; + @BeforeEach void resetMock(){reset(service);} + @AfterEach void clear(){SecurityContextHolder.clearContext();} + @Test void permissionRequiredBeforeHistoryAccess() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_DECIDE")))); + assertThrows(AccessDeniedException.class,()->controller.read("project","target","source",0,"snapshot"));verifyNoInteractions(service); + } + @Test void httpBindsSelectedSourceFindingAndProjectTokenAndNeverCaches() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_READ")))); + when(service.read("project","target","source",3,"snapshot")).thenReturn(new PriorFindingReasoningService.Reasoning("snapshot","source","1",1,List.of(),List.of(),0)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + String path="/api/v1/admin/projects/project/versions/target/prior-finding-reasoning"; + mvc.perform(get(path).param("sourceVersionId","source").param("issueIndex","3")).andExpect(status().isBadRequest());verifyNoInteractions(service); + mvc.perform(get(path).param("sourceVersionId","source").param("issueIndex","3").header("If-Match","snapshot")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.reviewToken").value("snapshot")); + verify(service).read("project","target","source",3,"snapshot"); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java index e062feb25..e6ab75cbb 100644 --- a/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java @@ -33,9 +33,9 @@ void publishProjectDelegatesWithTheResolvedCurrentUser() { admin.setId("admin-1"); when(accountService.requireCurrentUser("publishing projects")).thenReturn(admin); - var response = controller.publishProject("project-1"); + var response = controller.publishProject("project-1", "review-token", "version-1"); assertEquals(200, response.getStatusCode().value()); - verify(projectReviewAdminService).publishProject(admin, "project-1"); + verify(projectReviewAdminService).publishProject(admin, "project-1", "review-token", "version-1"); } } diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java new file mode 100644 index 000000000..96517bce4 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java @@ -0,0 +1,107 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewCancellationControllerTest.Config.class) +class ReviewCancellationControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewCancellationAccess access(){return mock(ReviewCancellationAccess.class);} + @Bean ReviewCancellationController controller(ReviewCancellationAccess access){return new ReviewCancellationController(access);} + @Bean(name="apiSecurity") ReviewRepairControllerTest.Permissions permissions(){return new ReviewRepairControllerTest.Permissions();} + } + @Autowired ReviewCancellationController controller;@Autowired ReviewCancellationAccess access; + String id="11111111-1111-1111-1111-111111111111",checkId="22222222-2222-2222-2222-222222222222"; + ReviewOrphanCancellationJournal.Prepared original=new ReviewOrphanCancellationJournal.Prepared(id,"a".repeat(64),1,60001); + String body(){return "{\"isolationId\":\""+id+"\",\"targetSha256\":\""+"a".repeat(64)+"\",\"createdAt\":1,\"expiresAt\":60001}";} + @BeforeEach void setup(){reset(access);auth("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + void auth(String...permissions){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("actor",null,Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));} + @Test void allRoutesRequireBothPermissionsBeforeAccess() { + for(String permission:List.of("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN","PROJECT_REVIEW_DECIDE","ROLE_USER")) { + auth(permission);assertThrows(AccessDeniedException.class,()->controller.history(null));assertThrows(AccessDeniedException.class,()->controller.preview(id));assertThrows(AccessDeniedException.class,()->controller.capabilities());assertThrows(AccessDeniedException.class,()->controller.prepare(null)); + assertThrows(AccessDeniedException.class,()->controller.execute(null));assertThrows(AccessDeniedException.class,()->controller.receipt(null)); + assertThrows(AccessDeniedException.class,()->controller.recover(id));assertThrows(AccessDeniedException.class,()->controller.check(null));assertThrows(AccessDeniedException.class,()->controller.checkReceipt(null)); + } + SecurityContextHolder.clearContext();var missing=assertThrows(IllegalArgumentException.class,()->controller.capabilities());Throwable root=missing;while(root.getCause()!=null)root=root.getCause();assertInstanceOf(org.springframework.security.authentication.AuthenticationCredentialsNotFoundException.class,root);verifyNoInteractions(access); + } + @Test void previewIsReadOnlyNoStoreAndContainsOnlyConfirmationFields()throws Exception { + when(access.preview(id)).thenReturn(new ReviewOrphanCancellationJournal.Preview(id,"OBJECT_ID","0123456789abcdef01234567",2,"v",checkId,id,"b".repeat(64),"a".repeat(64),"c".repeat(64))); + MockMvcBuilders.standaloneSetup(controller).build().perform(get("/api/v1/admin/verification/cancellations/targets/"+id)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.projectIdType").value("OBJECT_ID")).andExpect(jsonPath("$.versionIndex").value(2)) + .andExpect(jsonPath("$.targetSha256").value("a".repeat(64))).andExpect(jsonPath("$.jobId").value(id)) + .andExpect(jsonPath("$.binding").doesNotExist()).andExpect(jsonPath("$.filePath").doesNotExist()).andExpect(jsonPath("$.actor").doesNotExist()) + .andExpect(jsonPath("$.origin").doesNotExist()).andExpect(jsonPath("$.token").doesNotExist()); + verify(access).preview(id);verifyNoMoreInteractions(access); + } + @Test void preparationAndExecutionRemainSeparateHttpActions()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).build();when(access.prepare(id)).thenReturn(original); + mvc.perform(post("/api/v1/admin/verification/cancellations/prepare").contentType("application/json").content("{\"isolationId\":\""+id+"\"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.isolationId").value(id)).andExpect(jsonPath("$.token").doesNotExist()); + verify(access).prepare(id);verifyNoMoreInteractions(access); + when(access.execute(original)).thenReturn(new ReviewOrphanCancellationExecutor.Execution("UNKNOWN",null)); + mvc.perform(post("/api/v1/admin/verification/cancellations/execute").contentType("application/json").content(body())) + .andExpect(status().isOk()).andExpect(jsonPath("$.state").value("UNKNOWN")).andExpect(header().string("Cache-Control","no-store"));verify(access).execute(original); + } + @Test void recoveryAndReceiptRoutesNeverDispatchNewChecks()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).build();var receipt=new ReviewOrphanCancellationJournal.Receipt(original,"UNKNOWN",null,null); + when(access.recover(id)).thenReturn(receipt);when(access.receipt(original)).thenReturn(receipt); + var request=new ReviewCancellationAccess.Check(checkId,original);when(access.checkReceipt(request)).thenReturn(new ReviewCancellationReconciler.Receipt(checkId,original,"READING",null,null)); + mvc.perform(get("/api/v1/admin/verification/cancellations/operations/"+id)).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.prepared.expiresAt").value(60001)); + mvc.perform(post("/api/v1/admin/verification/cancellations/receipt").contentType("application/json").content(body())).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")); + mvc.perform(post("/api/v1/admin/verification/cancellations/checks/receipt").contentType("application/json").content("{\"id\":\""+checkId+"\",\"original\":"+body()+"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.state").value("READING")); + verify(access).recover(id);verify(access).receipt(original);verify(access).checkReceipt(request);verifyNoMoreInteractions(access); + } + @Test void explicitCheckPreservesItsOwnIdentityAndOriginalIntent()throws Exception { + var request=new ReviewCancellationAccess.Check(checkId,original); + when(access.check(request)).thenReturn(new ReviewCancellationReconciler.Receipt(checkId,original,"UNKNOWN",null,null)); + MockMvcBuilders.standaloneSetup(controller).build().perform(post("/api/v1/admin/verification/cancellations/checks").contentType("application/json").content("{\"id\":\""+checkId+"\",\"original\":"+body()+"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.id").value(checkId)).andExpect(jsonPath("$.original.isolationId").value(id)); + verify(access).check(request);verifyNoMoreInteractions(access); + } + @Test void errorsAndMalformedBodiesAreEmptyAndNotCacheable()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).build(); + for(var failure:List.of(new SecurityException("private"),new IllegalArgumentException("private"),new IllegalStateException("private"),new com.mongodb.MongoException("private"))) { + when(access.recover(id)).thenThrow(failure);int code=failure instanceof SecurityException?403:failure instanceof IllegalArgumentException?400:failure instanceof IllegalStateException?409:503; + mvc.perform(get("/api/v1/admin/verification/cancellations/operations/"+id)).andExpect(status().is(code)).andExpect(content().string("")).andExpect(header().string("Cache-Control","no-store"));reset(access); + } + mvc.perform(post("/api/v1/admin/verification/cancellations/execute").contentType("application/json").content("{")) + .andExpect(status().isBadRequest()).andExpect(header().string("Cache-Control","no-store"));verifyNoInteractions(access); + } + @Test void capabilityIsAccountGatedAndNoStore()throws Exception { + when(access.capability()).thenReturn(new ReviewCancellationAccess.Capability("CANCEL_ORIGINAL_REVIEW")); + MockMvcBuilders.standaloneSetup(controller).build().perform(get("/api/v1/admin/verification/cancellations/capabilities")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.action").value("CANCEL_ORIGINAL_REVIEW")); + } + @Test void controllerAbsentUnlessCancellationExplicitlyEnabled() { + new org.springframework.boot.test.context.runner.ApplicationContextRunner().withUserConfiguration(ReviewCancellationController.class) + .withPropertyValues("app.warden.repair.enabled=true","app.warden.jobs.enabled=true") + .run(c->{assertNull(c.getStartupFailure());assertFalse(c.containsBean("reviewCancellationController"));}); + } + @Test void historyReturnsOnlyReferencesWithoutDispatchAndIsNotCacheable()throws Exception { + var request=new ReviewCancellationAccess.History(original,null,10); + when(access.history(request)).thenReturn(new ReviewObservationReader.Page(List.of(new ReviewObservationReader.Item(checkId,"READING")),null,"OBSERVATION_ID")); + MockMvcBuilders.standaloneSetup(controller).build().perform(post("/api/v1/admin/verification/cancellations/checks/history") + .contentType("application/json").content("{\"original\":"+body()+",\"cursor\":null,\"limit\":10}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.items[0].id").value(checkId)).andExpect(jsonPath("$.items[0].recordedState").value("READING")) + .andExpect(jsonPath("$.items[0].observation").doesNotExist()).andExpect(jsonPath("$.order").value("OBSERVATION_ID")); + verify(access).history(request);verifyNoMoreInteractions(access); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationSecurityChainTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationSecurityChainTest.java new file mode 100644 index 000000000..9b9be1275 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationSecurityChainTest.java @@ -0,0 +1,106 @@ +package net.modtale.controller.admin; + +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.security.*; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.user.*; +import net.modtale.service.auth.*; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.mock.web.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.context.*; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.*; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.test.web.servlet.*; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ReviewCancellationSecurityChainTest { + @Configuration @EnableWebSecurity @EnableMethodSecurity @EnableWebMvc + static class Config { + @Bean AccountService accounts(){return mock(AccountService.class);} + @Bean ApiKeyService keys(){return mock(ApiKeyService.class);} + @Bean ClientRegistrationRepository clients(){return mock(ClientRegistrationRepository.class);} + @Bean ReviewOrphanCancellationJournal journal(){return mock(ReviewOrphanCancellationJournal.class);} + @Bean ReviewOrphanCancellationExecutor executor(){return mock(ReviewOrphanCancellationExecutor.class);} + @Bean ReviewCancellationReconciler reconciler(){return mock(ReviewCancellationReconciler.class);} + @Bean ReviewRepairWorkflow workflow(){return new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1);} + @Bean ReviewCancellationAccess access(AccountService a,ReviewRepairWorkflow w,ReviewOrphanCancellationJournal j,ReviewOrphanCancellationExecutor e,ReviewCancellationReconciler r){return new ReviewCancellationAccess(a,w,j,e,r);} + @Bean ReviewCancellationController controller(ReviewCancellationAccess a){return new ReviewCancellationController(a);} + @Bean(name="apiSecurity") AccessControlService permissions(AccountService a){return new AccessControlService(a,null,null,null);} + @Bean SecurityFilterChain chain(HttpSecurity http,ApiKeyService keys,AccountService accounts)throws Exception { + var api=new ApiKeyAuthFilter(keys,(request,response,handler,failure)->{response.setStatus(401);return new org.springframework.web.servlet.ModelAndView();}); + var configuration=new SecurityConfig(api,new RateLimitFilter(keys),mock(OAuth2LoginService.class),mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class),mock(LocalUserDetailsService.class),mock(PasswordEncoder.class),accounts, + mock(AuthenticationService.class),mock(LauncherAuthService.class),new AppFrontendProperties("http://localhost:3000")); + return configuration.securityFilterChain(http,mock(OAuth2AuthorizationRequestResolver.class)); + } + } + AnnotationConfigWebApplicationContext context;MockMvc mvc;ReviewOrphanCancellationJournal journal;ApiKeyService keys; + AtomicReference current=new AtomicReference<>();MockHttpSession session; + String prefix="/api/v1/admin/verification/cancellations",id="11111111-1111-1111-1111-111111111111"; + @BeforeEach void setup() { + context=new AnnotationConfigWebApplicationContext();context.setServletContext(new MockServletContext());context.register(Config.class);context.refresh(); + mvc=MockMvcBuilders.webAppContextSetup(context).addFilters(context.getBean("springSecurityFilterChain",jakarta.servlet.Filter.class)).build(); + journal=context.getBean(ReviewOrphanCancellationJournal.class);keys=context.getBean(ApiKeyService.class); + var actor=new User();actor.setId("actor");actor.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ,AdminPermission.PROJECT_VERSION_RESCAN));current.set(actor); + when(context.getBean(AccountService.class).getCurrentUser(any())).thenAnswer(i->{var auth=i.getArgument(0);return auth!=null && auth.getPrincipal() instanceof User?current.get():null;}); + session=new MockHttpSession();session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,new SecurityContextImpl(new UsernamePasswordAuthenticationToken(actor,null,List.of()))); + when(journal.prepare(eq(id),eq("actor"),any())).thenReturn(new ReviewOrphanCancellationJournal.Prepared(id,"a".repeat(64),1,60001)); + } + @AfterEach void cleanup(){context.close();SecurityContextHolder.clearContext();} + MockHttpServletRequestBuilder prepare(){return post(prefix+"/prepare").session(session).header("User-Agent","Mozilla/5.0").contentType("application/json").content("{\"isolationId\":\""+id+"\"}");} + MockHttpServletRequestBuilder csrf(MockHttpServletRequestBuilder r){return r.cookie(new jakarta.servlet.http.Cookie("XSRF-TOKEN","fixture-csrf")).header("X-XSRF-TOKEN","fixture-csrf");} + @Test void sessionMutationRequiresMatchingCsrfBeforeJournalAccess()throws Exception { + mvc.perform(prepare()).andExpect(status().isForbidden());verifyNoInteractions(journal); + mvc.perform(prepare().cookie(new jakarta.servlet.http.Cookie("XSRF-TOKEN","one")).header("X-XSRF-TOKEN","other")).andExpect(status().isForbidden());verifyNoInteractions(journal); + mvc.perform(csrf(prepare())).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store"));verify(journal).prepare(eq(id),eq("actor"),any()); + } + @Test void anonymousAndRevokedSessionsCannotReachTheJournal()throws Exception { + mvc.perform(get(prefix+"/capabilities").header("User-Agent","Mozilla/5.0").header("Origin","http://localhost:3000")).andExpect(status().isUnauthorized()); + current.get().setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ));mvc.perform(csrf(prepare())).andExpect(status().isForbidden()); + current.set(null);mvc.perform(csrf(prepare())).andExpect(status().isForbidden());verifyNoInteractions(journal); + } + @Test void suppliedInvalidApiKeyCannotFallBackToModeratorSession()throws Exception { + mvc.perform(csrf(prepare()).header("X-MODTALE-KEY","invalid-fixture-key")).andExpect(status().isUnauthorized());verifyNoInteractions(journal); + } + @Test void validApiKeyCannotUseBrowserOnlyModerationRoutes()throws Exception { + var key=new ApiKey();key.setId("fixture-key-id");when(keys.resolveKey("valid-fixture-key")).thenReturn(key);when(keys.getUserFromKey(key)).thenReturn(current.get()); + mvc.perform(prepare().header("X-MODTALE-KEY","valid-fixture-key")).andExpect(status().isForbidden());verifyNoInteractions(journal); + } + @Test void foreignOriginCannotUseValidSessionAndCsrf()throws Exception { + mvc.perform(csrf(prepare()).header("Origin","https://untrusted.example")).andExpect(status().isForbidden());verifyNoInteractions(journal); + } + @Test void everyCancellationPostRequiresBrowserCsrf()throws Exception { + for(String route:List.of("prepare","execute","receipt","checks","checks/receipt","checks/history")) { + mvc.perform(post(prefix+"/"+route).session(session).header("User-Agent","Mozilla/5.0").contentType("application/json").content("{}")) + .andExpect(status().isForbidden()); + } + verifyNoInteractions(journal,context.getBean(ReviewOrphanCancellationExecutor.class),context.getBean(ReviewCancellationReconciler.class)); + } + @Test void anotherAccountCannotSupplyTheOriginalActorsAuthority()throws Exception { + var other=new User();other.setId("other");other.setAdminPermissions(current.get().getAdminPermissions());current.set(other); + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,new SecurityContextImpl(new UsernamePasswordAuthenticationToken(other,null,List.of()))); + when(journal.prepare(eq(id),eq("other"),any())).thenThrow(new SecurityException("Original actor required")); + mvc.perform(csrf(prepare())).andExpect(status().isForbidden()); + verify(journal).prepare(eq(id),eq("other"),any());verify(journal,never()).prepare(eq(id),eq("actor"),any()); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java new file mode 100644 index 000000000..f2534a6b1 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java @@ -0,0 +1,69 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewOriginControllerTest.Config.class) +class ReviewOriginControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewOriginInventory inventory(){return mock(ReviewOriginInventory.class);} + @Bean ReviewOriginController controller(ReviewOriginInventory inventory){return new ReviewOriginController(inventory);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions {public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null && auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));}} + @Autowired ReviewOriginController controller;@Autowired ReviewOriginInventory inventory; + void auth(String permission){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority(permission))));} + @BeforeEach void setup(){reset(inventory);auth("PROJECT_REVIEW_READ");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + @Test void permissionPrecedesValidationAndDatabaseAccess() { + for(String p:List.of("PROJECT_REVIEW_DECIDE","PROJECT_VERSION_RESCAN","ROLE_USER","ROLE_ANONYMOUS")){auth(p);assertThrows(AccessDeniedException.class,()->controller.read("bad",0));}verifyNoInteractions(inventory); + } + @Test void cursorScopeAndCanonicalEncodingAreStrict() { + for(Object id:List.of("a",new ObjectId("abcdefabcdefabcdefabcdef")))for(boolean after:List.of(false,true)){ + var cursor=new RemoteReviewDiscovery.Cursor(id,after?0:12,after);assertEquals(cursor,ReviewOriginCursor.decode(ReviewOriginCursor.encode(cursor))); + assertThrows(IllegalArgumentException.class,()->ReviewOriginCursor.decode(ReviewStateDiagnosticCursor.encode(cursor))); + assertThrows(IllegalArgumentException.class,()->ReviewStateDiagnosticCursor.decode(ReviewOriginCursor.encode(cursor))); + } + for(String token:List.of("o1.a.1.s.1.YQ","o1.v.1.s.00.YQ","o1.v.1.s.0.YQ==","o1.v.1.o.0.YQ","o1.v.1.s.0.YR"))assertThrows(IllegalArgumentException.class,()->ReviewOriginCursor.decode(token)); + } + @Test void invalidHttpParametersNeverReadRecords()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(String limit:List.of("0","65","no","2147483648"))mvc.perform(get("/api/v1/admin/verification/origins/page").param("limit",limit)).andExpect(status().isBadRequest()); + for(String cursor:List.of("bad","d1.v.1.s.0.YQ","1.s.0.YQ"))mvc.perform(get("/api/v1/admin/verification/origins/page").param("cursor",cursor)).andExpect(status().isBadRequest());verifyNoInteractions(inventory); + } + @Test void emptyPageKeepsContinuationAndDoesNotClaimCompletion()throws Exception { + var next=new RemoteReviewDiscovery.Cursor("a",25,false);when(inventory.page(null,25)).thenReturn(new ReviewOriginInventory.Page(List.of(),next,25)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/origins/page")).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.scope").value("RETAINED_REVIEW_ORIGINS")).andExpect(jsonPath("$.items").isEmpty()).andExpect(jsonPath("$.nextCursor").value(ReviewOriginCursor.encode(next))).andExpect(jsonPath("$.examinedSlots").value(25)); + } + @Test void recordsPreserveBsonIdentityAndAmbiguityWithoutOriginPayload()throws Exception { + String hex="abcdefabcdefabcdefabcdef"; + when(inventory.page(null,25)).thenReturn(new ReviewOriginInventory.Page(List.of(new ReviewOriginInventory.Item(new ObjectId(hex),2,null,true,null,null,ReviewOriginInventory.OriginState.UNREADABLE_BINDING)),null,3)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/origins/page")).andExpect(status().isOk()).andExpect(jsonPath("$.items[0].position.projectIdType").value("OBJECT_ID")) + .andExpect(jsonPath("$.items[0].position.projectId").value(hex)).andExpect(jsonPath("$.items[0].ambiguousVersion").value(true)) + .andExpect(jsonPath("$.items[0].originState").value("UNREADABLE_BINDING")).andExpect(jsonPath("$.items[0].deploymentId").doesNotExist()).andExpect(jsonPath("$.items[0].callerScope").doesNotExist()); + } + @Test void databaseFailuresHaveGenericOperationalResponse()throws Exception { + when(inventory.page(null,25)).thenThrow(new IllegalStateException("private connection details")); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var response=mvc.perform(get("/api/v1/admin/verification/origins/page")).andExpect(status().isServiceUnavailable()).andReturn().getResponse();assertFalse(response.getContentAsString().contains("private connection details")); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java new file mode 100644 index 000000000..3063a593d --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java @@ -0,0 +1,80 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewRepairControllerTest.Config.class) +class ReviewRepairControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewRepairAccess access(){return mock(ReviewRepairAccess.class);} + @Bean ReviewRepairController controller(ReviewRepairAccess access){return new ReviewRepairController(access);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired ReviewRepairController controller;@Autowired ReviewRepairAccess access; + @BeforeEach void setup(){reset(access);auth("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + void auth(String...permissions){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("actor",null,Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));} + @Test void everyRouteRequiresBothPermissionsBeforeServiceAccess() { + for(String permission:List.of("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN","PROJECT_REVIEW_DECIDE","ROLE_USER")) { + auth(permission);assertThrows(AccessDeniedException.class,()->controller.closeExpired(null));assertThrows(AccessDeniedException.class,()->controller.operations(null,25));assertThrows(AccessDeniedException.class,()->controller.recover("invalid"));assertThrows(AccessDeniedException.class,()->controller.capabilities());assertThrows(AccessDeniedException.class,()->controller.inspect(null));assertThrows(AccessDeniedException.class,()->controller.prepare(null)); + assertThrows(AccessDeniedException.class,()->controller.execute(null));assertThrows(AccessDeniedException.class,()->controller.receipt(null)); + } + verifyNoInteractions(access); + } + @Test void preparedAndReceiptResponsesAreBoundedAndNotCacheable()throws Exception { + var prepared=new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(),"a".repeat(64),1,2); + when(access.prepare(any())).thenReturn(prepared);when(access.receipt(any())).thenReturn(new ReviewRepairAccess.Receipt(new ReviewRepairAccess.Position("STRING","p",0),"v",prepared.sha256(),"UNKNOWN",null)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(post("/api/v1/admin/verification/repairs/prepare").contentType("application/json").content("{\"id\":\""+prepared.id()+"\",\"position\":{\"projectIdType\":\"STRING\",\"projectId\":\"p\",\"versionIndex\":0},\"versionId\":\"v\",\"expectedSha256\":\""+prepared.sha256()+"\"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.id").value(prepared.id())).andExpect(jsonPath("$.actor").doesNotExist()); + mvc.perform(post("/api/v1/admin/verification/repairs/receipt").contentType("application/json").content("{\"id\":\""+prepared.id()+"\",\"sha256\":\""+prepared.sha256()+"\",\"createdAt\":1,\"expiresAt\":2}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.state").value("UNKNOWN")); + } + @Test void serviceErrorsNeverExposeStoredPayloads()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(var failure:List.of(new SecurityException("private payload"),new IllegalArgumentException("private payload"),new IllegalStateException("private payload"),new com.mongodb.MongoException("private payload"))) { + when(access.inspect(any())).thenThrow(failure);int expected=failure instanceof SecurityException?403:failure instanceof IllegalArgumentException?400:failure instanceof IllegalStateException?409:503; + mvc.perform(post("/api/v1/admin/verification/repairs/inspect").contentType("application/json").content("{}")) + .andExpect(status().is(expected)).andExpect(header().string("Cache-Control","no-store")).andExpect(content().string(""));reset(access); + } + } + @Test void controllerIsAbsentUnlessFeatureExplicitlyEnabled() { + new org.springframework.boot.test.context.runner.ApplicationContextRunner().withUserConfiguration(ReviewRepairController.class) + .run(context->{assertNull(context.getStartupFailure());assertFalse(context.containsBean("reviewRepairController"));}); + } + @Test void capabilityUsesAuthenticatedNoStoreContract()throws Exception { + when(access.capability()).thenReturn(new ReviewRepairAccess.Capability("ISOLATE_LOCAL_REVIEW")); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/repairs/capabilities")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.action").value("ISOLATE_LOCAL_REVIEW")); + } + + @Test void operationDiscoveryAndRecoveryUseNoStoreHttpResponses()throws Exception { + String id="11111111-1111-1111-1111-111111111111";var prepared=new ReviewRepairPreparation.Prepared(id,"a".repeat(64),1,2); + when(access.operations(null,25)).thenReturn(new ReviewRepairOperationReader.Page(List.of(new ReviewRepairOperationReader.Item(id,"UNKNOWN")),null,"OPERATION_ID")); + when(access.recover(id)).thenReturn(new ReviewRepairAccess.Recovered(prepared,new ReviewRepairAccess.Receipt(new ReviewRepairAccess.Position("STRING","p",0),"v",prepared.sha256(),"UNKNOWN",null))); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/repairs/operations")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.items[0].id").value(id)); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/repairs/operations/"+id)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.prepared.id").value(id)).andExpect(jsonPath("$.receipt.beforeSha256").value(prepared.sha256())); + } + +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java new file mode 100644 index 000000000..9d618fb9f --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java @@ -0,0 +1,71 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewStateDiagnosticControllerTest.Config.class) +class ReviewStateDiagnosticControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewStateDiagnosticReader reader(){return mock(ReviewStateDiagnosticReader.class);} + @Bean ReviewStateDiagnosticController controller(ReviewStateDiagnosticReader reader){return new ReviewStateDiagnosticController(reader);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null && auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired ReviewStateDiagnosticController controller; + @Autowired ReviewStateDiagnosticReader reader; + void auth(String permission){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority(permission))));} + @BeforeEach void setup(){reset(reader);auth("PROJECT_REVIEW_READ");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + @Test void permissionPrecedesEvenInvalidCursorAndDatabaseAccess() { + for(String permission:List.of("PROJECT_REVIEW_DECIDE","ROLE_USER","ROLE_ANONYMOUS")) { + auth(permission);assertThrows(AccessDeniedException.class,()->controller.read("bad",0)); + } + verifyNoInteractions(reader); + } + @Test void emptyDiagnosticPageRetainsPhysicalContinuation()throws Exception { + var next=new RemoteReviewDiscovery.Cursor("a",25,false); + when(reader.page(null,25)).thenReturn(new ReviewStateDiagnosticReader.Page(List.of(),next,25)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/diagnostics/page")).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.items").isEmpty()).andExpect(jsonPath("$.nextCursor").value(ReviewStateDiagnosticCursor.encode(next))) + .andExpect(jsonPath("$.examinedSlots").value(25)).andExpect(jsonPath("$.scope").value("PENDING_SCAN_STRUCTURE")); + } + @Test void invalidHttpParametersNeverReachDatabase()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(String limit:List.of("0","65","no","2147483648"))mvc.perform(get("/api/v1/admin/verification/diagnostics/page").param("limit",limit)).andExpect(status().isBadRequest()); + for(String cursor:List.of("bad","1.s.0.YQ","d1.a.1.s.1.YQ"))mvc.perform(get("/api/v1/admin/verification/diagnostics/page").param("cursor",cursor)).andExpect(status().isBadRequest()); + verifyNoInteractions(reader); + } + @Test void responsePreservesAmbiguousBsonIdentityWithoutFlattening()throws Exception { + String hex="abcdefabcdefabcdefabcdef";var reasons=List.of(ReviewStateDiagnosticReader.Reason.DUPLICATE_VERSION_ID); + var cursor=new RemoteReviewDiscovery.Cursor(hex,1,false); + when(reader.page(cursor,2)).thenReturn(new ReviewStateDiagnosticReader.Page(List.of( + new ReviewStateDiagnosticReader.Item(hex,1,null,reasons),new ReviewStateDiagnosticReader.Item(new ObjectId(hex),0,"v",reasons)),null,2)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/diagnostics/page").param("limit","2").param("cursor",ReviewStateDiagnosticCursor.encode(cursor))) + .andExpect(status().isOk()).andExpect(jsonPath("$.items[0].position.projectIdType").value("STRING")) + .andExpect(jsonPath("$.items[1].position.projectIdType").value("OBJECT_ID")) + .andExpect(jsonPath("$.items[0].position.projectId").value(hex)).andExpect(jsonPath("$.items[1].position.projectId").value(hex)) + .andExpect(jsonPath("$.items[0].position.versionIndex").value(1)).andExpect(jsonPath("$.items[0].versionId").isEmpty()) + .andExpect(jsonPath("$.items[0].reasons[0]").value("DUPLICATE_VERSION_ID")).andExpect(jsonPath("$.nextCursor").isEmpty()); + verify(reader).page(cursor,2); + } +} diff --git a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java index 0ad7499b4..d819a623d 100644 --- a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java +++ b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java @@ -165,6 +165,7 @@ void verificationQueueMappingSelectsPendingVersionAndKeepsScanPayloadSmall() { pendingVersion.setChangelog("Needs review"); pendingVersion.getScanResult().setStatus(ScanStatus.SUSPICIOUS); pendingVersion.getScanResult().setVerdict("REVIEW"); + pendingVersion.getScanResult().setScanState("REMOTE_HELD"); pendingVersion.getScanResult().setRiskScore(73); pendingVersion.getScanResult().setKnownIssueCount(2); pendingVersion.getScanResult().setNewIssueCount(3); @@ -177,6 +178,7 @@ void verificationQueueMappingSelectsPendingVersionAndKeepsScanPayloadSmall() { assertEquals("Needs review", item.pendingVersion().changelog()); assertEquals(ScanStatus.SUSPICIOUS, item.pendingVersion().scan().status()); assertEquals("REVIEW", item.pendingVersion().scan().verdict()); + assertEquals("REMOTE_HELD", item.pendingVersion().scan().scanState()); assertEquals(73, item.pendingVersion().scan().riskScore()); assertEquals(3, item.pendingVersion().scan().newIssueCount()); assertEquals(1, item.pendingVersion().scan().escalatedIssueCount()); diff --git a/backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java b/backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java new file mode 100644 index 000000000..cdc0db524 --- /dev/null +++ b/backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java @@ -0,0 +1,19 @@ +package net.modtale.model.project; + +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class RemoteReviewOriginTest { + @Test void rejectsMalformedOriginAndPreservesValidOriginOnAttachment() { + String id="abcdefab-1111-1111-1111-111111111111";var origin=new RemoteReviewOrigin(id,"a".repeat(64)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewOrigin(id.toUpperCase(),"a".repeat(64))); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewOrigin(id,null));assertThrows(IllegalArgumentException.class,()->new RemoteReviewOrigin(null,"a".repeat(64))); + var binding=new RemoteReviewBinding("p","v",UUID.randomUUID().toString(),1,"file","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null,false,origin); + assertEquals(origin,binding.withJobId(UUID.randomUUID().toString()).origin()); + } + @Test void legacyConstructorDoesNotInventAnOrigin() { + var legacy=new RemoteReviewBinding("p","v",UUID.randomUUID().toString(),1,"file","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null); + assertNull(legacy.origin());assertNull(legacy.withJobId(UUID.randomUUID().toString()).origin()); + } +} diff --git a/backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java b/backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java new file mode 100644 index 000000000..992a2a2b3 --- /dev/null +++ b/backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java @@ -0,0 +1,19 @@ +package net.modtale.model.project; + +import org.junit.jupiter.api.Test; +import java.util.Date; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewIsolationProvenanceTest { + @Test void provenanceDoesNotExposeMutableDates() { + var date=new Date(1000);var record=new ProjectVersion.ReviewIsolation("11111111-1111-1111-1111-111111111111","actor","a".repeat(64),date); + date.setTime(2000);record.isolatedAt().setTime(3000);assertEquals(1000,record.isolatedAt().getTime()); + } + @Test void publicJsonNeitherExposesNorAcceptsProvenance()throws Exception { + var mapper=new com.fasterxml.jackson.databind.ObjectMapper();var version=new ProjectVersion();version.setVersionNumber("v"); + version.setReviewIsolation(new ProjectVersion.ReviewIsolation("11111111-1111-1111-1111-111111111111","actor","a".repeat(64),new Date(1000))); + String json=mapper.writeValueAsString(version);assertFalse(json.contains("reviewIsolation"));assertFalse(json.contains("actor")); + var incoming=mapper.readValue("{\"versionNumber\":\"v\",\"reviewIsolation\":{\"operationId\":\"forged\",\"actorId\":\"forged\"}}",ProjectVersion.class); + assertNull(incoming.getReviewIsolation());assertEquals("v",incoming.getVersionNumber()); + } +} diff --git a/backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java b/backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java new file mode 100644 index 000000000..44b990107 --- /dev/null +++ b/backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java @@ -0,0 +1,41 @@ +package net.modtale.model.project; + +import net.modtale.config.db.SecurityEvidenceConverters; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class SecurityManifestTest { + @Test void streamedIdentityMatchesCanonicalOrderingAndUtf16PathLengths() throws Exception { + var entries = new LinkedHashMap(); + entries.put("z/😀.class", "a".repeat(64)); entries.put("a.json", "b".repeat(64)); + StringBuilder canonical = new StringBuilder(); + new TreeMap<>(entries).forEach((path, hash) -> canonical.append(path.length()).append(':').append(path).append(':').append(hash).append('\n')); + String expected = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical.toString().getBytes(StandardCharsets.UTF_8))); + assertEquals(expected, SecurityManifest.identity(entries)); + } + @Test void aggregatePathBudgetAppliesToValidationAndPersistence() { + var entries = new LinkedHashMap(); + for (int i = 0; i < 126; i++) entries.put(i + "x".repeat(8000), "a".repeat(64)); + assertFalse(SecurityManifest.valid(entries, false)); + assertThrows(Exception.class, () -> SecurityManifest.identity(entries)); + var evidence = new ScanResult.SecurityEvidence("policy", "a".repeat(64), "b".repeat(64), true, true, "COMPLETED", entries); + assertThrows(Exception.class, () -> new SecurityEvidenceConverters.Write().convert(evidence)); + assertThrows(Exception.class, () -> new SecurityEvidenceConverters.Read().convert(new Document("entryHashes", entries))); + } + @Test void exactAggregateBoundaryIsAcceptedAndOneMorePathIsRejected() { + var entries = new LinkedHashMap(); + for (int i = 0; i < 125; i++) entries.put(String.format("%04d", i) + "x".repeat(7996), "a".repeat(64)); + assertTrue(SecurityManifest.valid(entries, false)); + entries.put("overflow", "a".repeat(64)); + assertFalse(SecurityManifest.valid(entries, false)); + } + @Test void compactApprovalSummariesAllowEmptyManifestsButCannotStandInForFullEvidence() { + assertTrue(SecurityManifest.valid(Map.of(), true)); + assertFalse(SecurityManifest.valid(Map.of(), false)); + assertFalse(SecurityManifest.valid(Map.of("manifest.json", "invalid"), false)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java b/backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java new file mode 100644 index 000000000..3fd330a9f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.admin.project; + +import java.util.ArrayList; +import java.util.List; +import net.modtale.model.project.*; +import net.modtale.model.user.User; +import net.modtale.service.admin.audit.AdminAuditLogger; +import net.modtale.service.admin.review.*; +import net.modtale.service.analytics.ScoringService; +import net.modtale.service.communication.NotificationService; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.lifecycle.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.ScanService; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProjectModerationServiceTest { + private final ProjectReviewPersistence writes = mock(ProjectReviewPersistence.class); + private final ProjectService projects = mock(ProjectService.class); + private final ProjectDeletionService deletion = mock(ProjectDeletionService.class); + private final NotificationService notifications = mock(NotificationService.class); + private final AdminAuditLogger audit = mock(AdminAuditLogger.class); + private final ProjectModerationService service = new ProjectModerationService(writes, projects, + mock(ProjectRetentionService.class), deletion, mock(ScoringService.class), notifications, + mock(ScanService.class), new ProjectVersionAccessService(null), audit); + private final User admin = new User(); + private ProjectReviewPersistence.Snapshot setup() { + var project = new Project(); project.setId("project"); project.setAuthorId("author"); project.setStatus(ProjectStatus.PUBLISHED); + var version = new ProjectVersion(); version.setId("v1"); + project.setVersions(new ArrayList<>(List.of(version))); admin.setId("admin"); + var snapshot = new ProjectReviewPersistence.Snapshot(new Document(), project); + when(projects.getRawProjectById("project")).thenReturn(project); + when(writes.capture(eq("project"), anyString())).thenReturn(snapshot); + when(projects.getProjectLink(project)).thenReturn("/project/project"); + return snapshot; + } + @Test void staleDeleteCannotRemoveFilesOrAnnounceSuccess() { + setup(); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.deleteProjectVersion(admin, "project", "v1")); + verifyNoInteractions(deletion, notifications, audit); + verify(projects, never()).evictProjectCache(any(Project.class)); + } + @Test void successfulDeletePersistsBeforeCleanup() { + var snapshot = setup(); var removed = snapshot.project().getVersions().getFirst(); + when(writes.applyVersionList(snapshot)).thenReturn(true); + service.deleteProjectVersion(admin, "project", "v1"); + assertTrue(snapshot.project().getVersions().isEmpty()); + var order = inOrder(writes, projects, deletion, audit); + order.verify(writes).applyVersionList(snapshot); + order.verify(projects).evictProjectCache(snapshot.project()); + order.verify(deletion).deleteVersionFile(removed); + order.verify(audit).logAction("admin", "DELETE_VERSION", "project", "VERSION", "VerID: v1"); + } + @Test void staleUnlistCannotNotifyOrEvict() { + setup(); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.unlistProject(admin, "project", "reason")); + verifyNoInteractions(notifications, audit); + verify(projects, never()).evictProjectCache(any(Project.class)); + } + @Test void successfulUnlistNotifiesAfterPersistence() { + var snapshot = setup(); when(writes.unlist(snapshot)).thenReturn(true); + service.unlistProject(admin, "project", "reason"); + var order = inOrder(writes, projects, notifications, audit); + order.verify(writes).unlist(snapshot); + order.verify(projects).evictProjectCache(snapshot.project()); + order.verify(notifications).sendNotifcation(anyList(), eq("Project Unlisted"), anyString(), any(), isNull()); + order.verify(audit).logAction("admin", "UNLIST_PROJECT", "project", "PROJECT", "Reason: reason"); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java new file mode 100644 index 000000000..4d48d143b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java @@ -0,0 +1,303 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import com.mongodb.client.model.*; +import java.util.*; +import net.modtale.config.db.*; +import net.modtale.config.properties.AppSecurityProperties; +import net.modtale.model.project.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.issue.*; +import net.modtale.service.security.scan.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import org.springframework.data.mongodb.core.convert.*; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ArtifactReviewLineageIntegrationTest { + private MongoClient client; private MongoTemplate mongo; private String database; + private final String id = "abcdefabcdefabcdefabcdef"; + private Project project; private ProjectVersion target; private ScanResult result; + @BeforeEach void setup() throws Exception { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27029"); + if (!Set.of("27029","27030").contains(port)) throw new IllegalArgumentException("Unexpected port"); + database="warden_origin_test_"+UUID.randomUUID().toString().replace("-",""); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + var factory=new SimpleMongoClientDatabaseFactory(client,database); + var conversions=new MongoConfig().mongoCustomConversions(new MongoArtifactManifestStore(factory)); + var context=new MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new MappingMongoConverter(new DefaultDbRefResolver(factory),context);converter.setCustomConversions(conversions);converter.afterPropertiesSet(); + mongo=spy(new MongoTemplate(factory,converter)); + var source=new ProjectVersion(); source.setId("source");source.setVersionNumber("1.0"); + result=ScanEvidenceFixtures.complete(false); + source.setHash(result.getSecurityEvidence().artifactSha256());source.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + source.setApprovedSecurityEvidence(result.getSecurityEvidence());source.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(source)); + source.setSecurityApprovalProjectId(id); + source.setSecurityApprovedAt(System.currentTimeMillis()-1000);source.setApprovedReviewOrigins(Map.of()); + target=new ProjectVersion();target.setId("target");target.setVersionNumber("2.0");target.setHash(source.getHash()); + var queued=new ScanResult();queued.setScanState("SCANNING");queued.setStatus(ScanStatus.SCANNING);queued.setScanAttempt(1);target.setScanResult(queued); + project=new Project();project.setId(id);project.setVersions(List.of(source,target));mongo.insert(project); + new ArtifactReviewReuseService().annotate(project,"target",result); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(target)); + assertNotNull(result.getReusedReviewOrigins()); + target.setScanResult(result); target.setSecurityApprovalProjectId(id); + new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2))) + .markIssuesAcceptedForApprovedVersion(target); + } + @AfterEach void cleanup() { if(client!=null){client.getDatabase(database).drop();client.close();} } + private boolean apply() { + return new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)).applyScanOutcome( + id,"target",1,result,new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW,0),target); + } + private RemoteReviewPollStore.Claim remoteClaim() { + String request=UUID.randomUUID().toString();target.setFileUrl("original.zip");result.setScanRequestId(request); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.1.fileUrl","original.zip").set("versions.1.scanResult.scanRequestId",request),Project.class); + var current=mongo.findById(id,Project.class).getVersions().get(1); + var binding=new RemoteReviewBinding(id,"target",request,1,"original.zip",target.getHash(),result.getReviewedContextSha256(),result.getSecurityEvidence().policyVersion(),"c".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + assertTrue(new RemoteReviewPersistence(mongo).bind(current,binding));var polls=new RemoteReviewPollStore(mongo); + var claim=polls.claim(binding,30000);assertNotNull(claim);return polls.attachJob(claim,UUID.randomUUID().toString()); + } + @Test void remoteCompletionCombinesLiveLeaseWithValidSourceGuard() { + var claim=remoteClaim();var poll=mongo.findById(id,Project.class).getVersions().get(1).getScanResult().getRemotePoll(); + assertTrue(new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)).applyRemoteScanOutcome(claim,poll,result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW,0),target)); + var stored=mongo.findById(id,Project.class);assertEquals(ProjectVersion.ReviewStatus.APPROVED,stored.getVersions().get(1).getReviewStatus()); + assertEquals(Set.of("source"),stored.getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void remoteHeldFallbackCannotWriteAfterPollOwnershipExpires() { + var claim=remoteClaim();var poll=mongo.findById(id,Project.class).getVersions().get(1).getScanResult().getRemotePoll(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.REJECTED) + .set("versions.1.scanResult.remotePoll.leaseUntil",new Date(0)),Project.class); + assertFalse(new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)).applyRemoteScanOutcome(claim,poll,result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW,0),target)); + assertEquals("REMOTE_REVIEW",mongo.findById(id,Project.class).getVersions().get(1).getScanResult().getScanState()); + } + @Test void approvalPersistsReasoningBindingAndSnapshotDetectsItsReplacement() { + var issue = new ScanResult.ScanIssue(); issue.setType("Network"); issue.setCategory("NETWORK"); + issue.setDescription("Documented connection"); issue.setFilePath("Mod.class"); issue.setSeverity("LOW"); + issue.setLineStart(9); issue.setLineEnd(11); result.setIssues(new ArrayList<>(List.of(issue))); + target.setScanResult(result); + new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2))) + .markIssuesAcceptedForApprovedVersion(target); + String expected = target.getApprovedIssueBaselines().getFirst().getReasoningEvidenceIdentity(); + assertTrue(expected.matches("re1:[0-9a-f]{64}")); + assertTrue(apply()); + var stored = mongo.findById(id, Project.class).getVersions().get(1); + assertEquals(expected, stored.getApprovedIssueBaselines().getFirst().getReasoningEvidenceIdentity()); + String token = VersionReviewSnapshot.token(stored); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.1.approvedIssueBaselines.0.reasoningEvidenceIdentity", "re1:" + "f".repeat(64)), Project.class); + var changed = mongo.findById(id, Project.class).getVersions().get(1); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> VersionReviewSnapshot.requireCurrent(changed, token)); + } + @Test void sourceGuardPublishesOnlyTargetAndRetainsOriginProof() { + assertTrue(apply());var stored=mongo.findById(id,Project.class); + assertEquals(ProjectVersion.ReviewStatus.APPROVED,stored.getVersions().get(1).getReviewStatus()); + assertEquals(Map.of(),stored.getVersions().getFirst().getApprovedReviewOrigins()); + assertEquals(Set.of("source"),stored.getVersions().get(1).getApprovedReviewOrigins().keySet()); + assertEquals("1.0",stored.getVersions().getFirst().getVersionNumber()); + assertNull(stored.getVersions().get(1).getScanResult()); + } + @Test void proofCannotBeAttachedToDifferentContentsOrExtendedApprovalTime() { + result.setReusedReviewApprovedAt(result.getReusedReviewApprovedAt()+1); + assertFalse(apply());assertHeld(); + } + @Test void proofForOtherContentsDoesNotAuthorizeCurrentResult() { + var evidence=result.getSecurityEvidence();var entries=Map.of("Changed.class","f".repeat(64)); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(),evidence.artifactSha256(), + SecurityManifest.identity(entries),true,false,evidence.reviewState(),entries)); + assertFalse(apply());assertHeld(); + } + @Test void objectIdBackedVersionIdentifiersRemainBoundToTheCorrectSource() { + var sourceId=new org.bson.types.ObjectId(); + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0._id",sourceId)); + var snapshot=mongo.findById(id,Project.class); + new ArtifactReviewReuseService().annotate(snapshot,"target",result); + target.setApprovedReviewOrigins(result.getReusedReviewOrigins()); + assertEquals(Set.of(sourceId.toHexString()),result.getReusedReviewOrigins().keySet()); + assertTrue(apply()); + assertEquals(Set.of(sourceId.toHexString()),mongo.findById(id,Project.class).getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void rejectionBeforePersistenceHoldsInsteadOfPublishing() { + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + assertFalse(apply());assertHeld(); + } + @Test void rejectionBetweenOriginReadAndWriteCannotPublishOrModifySource() { + doAnswer(invocation -> { + Query query=invocation.getArgument(0); + if(query.getQueryObject().containsKey("$expr")) + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + return invocation.callRealMethod(); + }).when(mongo).updateFirst(any(Query.class),any(Update.class),eq(Project.class)); + assertFalse(apply());assertHeld(); + assertEquals(ProjectVersion.ReviewStatus.REJECTED,mongo.findById(id,Project.class).getVersions().getFirst().getReviewStatus()); + } + private Project readyForManual() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.1.scanResult",result).set("status",ProjectStatus.PENDING),Project.class); + return mongo.findById(id,Project.class); + } + private void accept(ProjectVersion version) { + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);version.setSecurityApprovalProjectId(id); + new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2))) + .markIssuesAcceptedForApprovedVersion(version); + } + @Test void manualVersionApprovalRetainsProof() { + var loaded=readyForManual();var version=loaded.getVersions().get(1); + var persistence=new VersionReviewPersistence(mongo); + var snapshot=persistence.capture(id,"target",VersionReviewSnapshot.token(version));accept(version); + assertTrue(persistence.apply(snapshot,version)); + assertEquals(Set.of("source"),mongo.findById(id,Project.class).getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void manualVersionApprovalRejectsSourceMutationAtItsAtomicWrite() { + var loaded=readyForManual();var version=loaded.getVersions().get(1); + var persistence=new VersionReviewPersistence(mongo); + var snapshot=persistence.capture(id,"target",VersionReviewSnapshot.token(version));accept(version); + var realCollection=mongo.getCollection("projects");var intercepted=spy(realCollection.withWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true))); + doReturn(intercepted).when(mongo).getCollection("projects"); + doReturn(intercepted).when(intercepted).withWriteConcern(any(com.mongodb.WriteConcern.class)); + doAnswer(invocation -> { + realCollection.updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + return invocation.callRealMethod(); + }).when(intercepted).updateOne(any(org.bson.conversions.Bson.class),any(org.bson.conversions.Bson.class),any(com.mongodb.client.model.UpdateOptions.class)); + assertFalse(persistence.apply(snapshot,version)); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(id,Project.class).getVersions().get(1).getReviewStatus()); + } + @Test void projectPublicationPreservesValidProof() { + var loaded=readyForManual();var persistence=new ProjectReviewPersistence(mongo); + var snapshot=persistence.capture(id,ProjectReviewSnapshot.token(loaded)); + accept(snapshot.project().getVersions().get(1));snapshot.project().setStatus(ProjectStatus.PUBLISHED); + assertTrue(persistence.apply(snapshot,"target")); + assertEquals(Set.of("source"),mongo.findById(id,Project.class).getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void freshlyOpenedProjectCannotPublishAnAlreadyRevokedReuse() { + readyForManual();mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + var loaded=mongo.findById(id,Project.class);var persistence=new ProjectReviewPersistence(mongo); + var snapshot=persistence.capture(id,ProjectReviewSnapshot.token(loaded)); + accept(snapshot.project().getVersions().get(1));snapshot.project().setStatus(ProjectStatus.PUBLISHED); + assertFalse(persistence.apply(snapshot,"target")); + assertNotEquals(ProjectStatus.PUBLISHED,mongo.findById(id,Project.class).getStatus()); + } + @Test void legacyReusedScanCannotBypassOriginsThroughManualApproval() { + result.setReusedReviewOrigins(null);var loaded=readyForManual();var version=loaded.getVersions().get(1); + var persistence=new VersionReviewPersistence(mongo); + var snapshot=persistence.capture(id,"target",VersionReviewSnapshot.token(version));accept(version); + assertFalse(persistence.apply(snapshot,version)); + } + private Project scheduled() { + mongo.getCollection("projects").updateOne(new Document(),Updates.combine( + Updates.set("versions.1.reviewStatus","SCHEDULED"),Updates.set("versions.1.scheduledPublishDate","2020-01-01T00:00:00"))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.1.scanResult",result),Project.class); + return mongo.findById(id,Project.class); + } + private List publishScheduled(Project snapshot, net.modtale.service.communication.ProjectNotificationService notifications) { + var classification=new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2)); + var analysis=new SecurityIssueAnalysisService(classification,new SecurityIssueApprovalService(classification)); + var warden=mock(WardenClientService.class);when(warden.currentPolicyVersion()).thenReturn(result.getSecurityEvidence().policyVersion()); + return new net.modtale.service.project.lifecycle.ScheduledReleaseExecutionService(mongo,mock(ProjectService.class),notifications,analysis,warden) + .publishDueVersions(snapshot,java.time.LocalDateTime.now()); + } + @Test void scheduledPublicationRetainsOriginsAndLeavesSourceUnchanged() { + var notifications=mock(net.modtale.service.communication.ProjectNotificationService.class); + assertEquals(List.of("2.0"),publishScheduled(scheduled(),notifications)); + var stored=mongo.findById(id,Project.class); + assertEquals(Set.of("source"),stored.getVersions().get(1).getApprovedReviewOrigins().keySet()); + assertEquals(id,stored.getVersions().get(1).getSecurityApprovalProjectId()); + assertEquals(Map.of(),stored.getVersions().getFirst().getApprovedReviewOrigins()); + } + @Test void scheduledPublicationRejectsSourceChangeAfterGuardWasBuilt() { + var snapshot=scheduled();var notifications=mock(net.modtale.service.communication.ProjectNotificationService.class); + doAnswer(invocation -> { + Query query=invocation.getArgument(0); + if(query.getQueryObject().containsKey("$expr")) mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.findingReviewHead","revocation")); + return invocation.callRealMethod(); + }).when(mongo).updateFirst(any(Query.class),any(Update.class),eq(Project.class)); + assertTrue(publishScheduled(snapshot,notifications).isEmpty());assertHeld();verifyNoInteractions(notifications); + assertNull(mongo.findById(id,Project.class).getVersions().get(1).getScheduledPublishDate()); + } + @Test void scheduledPublicationRejectsAlreadyChangedSource() { + var snapshot=scheduled();var notifications=mock(net.modtale.service.communication.ProjectNotificationService.class); + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + assertTrue(publishScheduled(snapshot,notifications).isEmpty());assertHeld();verifyNoInteractions(notifications); + } + private FindingReviewService.Event approveSourceWithRecordedFinding() { + var source = mongo.findById(id, Project.class).getVersions().getFirst(); + var sourceScan = ScanEvidenceFixtures.complete(false); + sourceScan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(source)); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); issue.setType("Network"); + issue.setDescription("Documented service integration"); issue.setSeverity("LOW"); + sourceScan.setIssues(new ArrayList<>(List.of(issue))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.scanResult", sourceScan), Project.class); + var persistence = new VersionReviewPersistence(mongo); + var history = new FindingReviewService(mongo, persistence, mock(ProjectService.class), mock(WardenClientService.class)); + source = mongo.findById(id, Project.class).getVersions().getFirst(); + var event = history.record(id, "source", VersionReviewSnapshot.token(source), "moderator", + new FindingReviewService.Request(0, FindingReviewService.Disposition.ACCEPT, "Checked the endpoint and all its callers")); + source = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.capture(id, "source", VersionReviewSnapshot.token(source)); + accept(source); assertTrue(persistence.apply(snapshot, source)); + source = mongo.findById(id, Project.class).getVersions().getFirst(); + assertEquals(event.id(), source.getApprovedFindingReviewHead()); + assertNull(source.getScanResult()); + result = ScanEvidenceFixtures.complete(false); + result.setIssues(new ArrayList<>(List.of(issue))); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(target)); + new ArtifactReviewReuseService().annotate(mongo.findById(id, Project.class), "target", result); + assertEquals("1.0", result.getReusedReviewVersion()); + assertTrue(result.getIssues().getFirst().isResolved()); + assertEquals(1, result.getKnownIssueCount()); + assertEquals(0, result.getNewIssueCount()); + target.setScanResult(result); accept(target); + return event; + } + @Test void approvedFindingHistoryCanClearAnExactArtifactWithoutRepeatingTheReview() { + approveSourceWithRecordedFinding(); + assertTrue(apply()); + var versions = mongo.findById(id, Project.class).getVersions(); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, versions.get(1).getReviewStatus()); + assertEquals(Set.of("source"), versions.get(1).getApprovedReviewOrigins().keySet()); + assertNull(versions.get(1).getApprovedFindingReviewHead()); + } + @Test void revokingApprovedSourceFindingInvalidatesTheCarriedApproval() { + var event = approveSourceWithRecordedFinding(); + var source = mongo.findById(id, Project.class).getVersions().getFirst(); + new FindingReviewService(mongo, new VersionReviewPersistence(mongo), mock(ProjectService.class), mock(WardenClientService.class)) + .revoke(id, "source", VersionReviewSnapshot.token(source), "moderator", event.id(), "New evidence invalidates the source conclusion"); + assertFalse(apply()); assertHeld(); + } + @Test void sourceHistoryChangeAtTheFinalWriteCannotPublishAnExactCopy() { + approveSourceWithRecordedFinding(); + doAnswer(invocation -> { + Query query = invocation.getArgument(0); + if (query.getQueryObject().containsKey("$expr")) + mongo.getCollection("projects").updateOne(new Document(), Updates.set("versions.0.findingReviewHead", "new-requirement")); + return invocation.callRealMethod(); + }).when(mongo).updateFirst(any(Query.class), any(Update.class), eq(Project.class)); + assertFalse(apply()); assertHeld(); + } + @Test void missingApprovedHistoryCannotAuthorizeReusedPublication() { + var event = approveSourceWithRecordedFinding(); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", event.id())); + assertFalse(apply()); assertHeld(); + } + @Test void anUnattestedHistoryHeadCannotBeMadeReusableByMarkingStatusApproved() { + approveSourceWithRecordedFinding(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.approvedFindingReviewHead", null), Project.class); + var fresh = ScanEvidenceFixtures.complete(false); + new ArtifactReviewReuseService().annotate(mongo.findById(id, Project.class), "target", fresh); + assertNull(fresh.getReusedReviewVersion()); + assertFalse(apply()); assertHeld(); + } + private void assertHeld() { + var stored=mongo.findById(id,Project.class).getVersions().get(1); + assertEquals(ProjectVersion.ReviewStatus.PENDING,stored.getReviewStatus()); + assertEquals(ScanStatus.SUSPICIOUS,stored.getScanResult().getStatus()); + assertNull(stored.getScanResult().getReusedReviewVersion()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java new file mode 100644 index 000000000..1edd74a80 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java @@ -0,0 +1,654 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import java.util.*; +import net.modtale.config.db.*; +import net.modtale.model.project.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.issue.FindingReviewService; +import net.modtale.service.security.scan.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import org.springframework.data.mongodb.core.convert.*; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.*; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST", matches="true") +class FindingReviewPersistenceIntegrationTest { + private MongoClient client; + private MongoTemplate mongo; + private VersionReviewPersistence persistence; + private FindingReviewService service; + private ProjectService projects; + private WardenClientService warden; + private String database; + private final String projectId = "abcdefabcdefabcdefabcdef"; + + @BeforeEach void setup() throws Exception { + String port = System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT", "27029"); + if (!Set.of("27029", "27030").contains(port)) throw new IllegalArgumentException("Unexpected test database port"); + database = "warden_finding_test_" + UUID.randomUUID().toString().replace("-", ""); + client = MongoClients.create("mongodb://127.0.0.1:" + port + "/?serverSelectionTimeoutMS=3000"); + var factory = new SimpleMongoClientDatabaseFactory(client, database); + var conversions = new MongoConfig().mongoCustomConversions(new MongoArtifactManifestStore(factory)); + var context = new MongoMappingContext(); context.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); context.afterPropertiesSet(); + var converter = new MappingMongoConverter(new DefaultDbRefResolver(factory), context); + converter.setCustomConversions(conversions); converter.afterPropertiesSet(); + mongo = new MongoTemplate(factory, converter); persistence = spy(new VersionReviewPersistence(mongo)); + projects = mock(ProjectService.class); warden = mock(WardenClientService.class); + when(warden.currentPolicyVersion()).thenReturn(ScanEvidenceFixtures.complete(false).getSecurityEvidence().policyVersion()); + service = new FindingReviewService(mongo, persistence, projects, warden); + var project = new Project(); project.setId(projectId); + var version = new ProjectVersion(); version.setId("v1"); version.setVersionNumber("1.0"); + var scan = ScanEvidenceFixtures.complete(false); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); issue.setType("Network"); + issue.setDescription("Connects to a service"); issue.setSeverity("LOW"); + scan.setIssues(new ArrayList<>(List.of(issue))); version.setScanResult(scan); + project.setVersions(List.of(version)); mongo.insert(project); + when(projects.getRawProjectById(projectId)).thenReturn(project); + } + @AfterEach void cleanup() { if (client != null) { client.getDatabase(database).drop(); client.close(); } } + private ProjectVersion version() { return mongo.findById(projectId, Project.class).getVersions().getFirst(); } + private String token() { return VersionReviewSnapshot.token(version()); } + private FindingReviewService.Event record(String token) { + return service.record(projectId, "v1", token, "moderator", new FindingReviewService.Request(0, + FindingReviewService.Disposition.ACCEPT, "Verified documented service integration")); + } + + @Test void persistsActorRationaleWholeArtifactScopeAndInvalidatesBrowserSnapshot() { + String before = token(); var event = record(before); + assertEquals("moderator", event.actorId()); assertEquals("WHOLE_ARTIFACT", event.scope()); + assertEquals(30L * 86400000, event.expiresAt() - event.createdAt()); + assertEquals(version().getHash(), event.artifactSha256()); + assertEquals(ArtifactReviewContext.fingerprint(version()), event.contextSha256()); + assertTrue(event.finding().identity().matches("ie1:[0-9a-f]{64}")); + assertNotEquals(before, token()); assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(event, service.history(projectId, "v1", token(), 0).events().getFirst()); + assertFalse(version().getScanResult().getIssues().getFirst().isResolved()); + assertFalse(ArtifactClearancePolicy.cleared(version().getScanResult())); + verify(projects).evictProjectCache(any(Project.class)); + } + @Test void decisionCancelsSchedulingAndCannotBeBypassedByAStaleScanOutcome() { + var previous = version(); + var scan = ScanEvidenceFixtures.complete(true); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(previous)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.SCHEDULED) + .set("versions.0.scheduledPublishDate", "2020-01-01T00:00:00"), Project.class); + record(token()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertNull(version().getScheduledPublishDate()); + var current = version(); current.setScanResult(scan); + assertFalse(ArtifactClearancePolicy.boundToVersion(current)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.scanResult.status", ScanStatus.SCANNING) + .set("versions.0.scanResult.scanState", "SCANNING"), Project.class); + var scans = new ScanPersistenceService(mongo, mock(net.modtale.repository.project.ProjectRepository.class), projects); + var approve = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW, 0); + assertFalse(scans.applyScanOutcome(projectId, "v1", 1, scan, approve, previous)); + assertFalse(scans.applyScanOutcome(projectId, "v1", 1, scan, approve, current)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void subsequentConclusionSupersedesOnlyTheSameReviewedScope() { + var first = record(token()); + var second = service.record(projectId, "v1", token(), "other-reviewer", new FindingReviewService.Request(0, + FindingReviewService.Disposition.REQUIRE_REVIEW, "The caller needs further investigation")); + assertEquals(first.id(), second.supersedesDecisionId()); + assertEquals(0, second.expiresAt()); + assertEquals(first, mongo.findById(first.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + assertEquals(2, service.history(projectId, "v1", token(), 0).events().size()); + } + @Test void historyExplainsScopeChangesAndUnavailablePolicy() { + var event = record(token()); + assertEquals("APPLICABLE", service.history(projectId, "v1", token(), 0).assessments().get(event.id()).state()); + when(warden.currentPolicyVersion()).thenReturn(null); + assertEquals("POLICY_UNAVAILABLE", service.history(projectId, "v1", token(), 0).assessments().get(event.id()).state()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.gameVersions", List.of("different-runtime")), Project.class); + assertEquals("CONTEXT_CHANGED", service.history(projectId, "v1", token(), 0).assessments().get(event.id()).state()); + } + @Test void historyRejectsVersionChangeDuringPolicyLookup() { + record(token()); + when(warden.currentPolicyVersion()).thenAnswer(invocation -> { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.hash", "f".repeat(64)), Project.class); + return ScanEvidenceFixtures.complete(false).getSecurityEvidence().policyVersion(); + }); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.history(projectId, "v1", token(), 0)).getStatusCode().value()); + } + @Test void staleConcurrentRecordCannotOverwriteHead() { + String before = token(); var winner = record(before); + assertEquals(409, assertThrows(ResponseStatusException.class, () -> record(before)).getStatusCode().value()); + assertEquals(winner.id(), version().getFindingReviewHead()); + assertEquals(1, mongo.getCollection(FindingReviewService.COLLECTION).countDocuments()); + } + @Test void artifactChangeBetweenInsertAndCasLeavesOnlyUnreachableEvent() { + doAnswer(invocation -> { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), + new Update().set("versions.0.hash", "f".repeat(64)), Project.class); + return invocation.callRealMethod(); + }).when(persistence).appendFindingReview(any(), anyString()); + assertEquals(409, assertThrows(ResponseStatusException.class, () -> record(token())).getStatusCode().value()); + assertNull(version().getFindingReviewHead()); + assertEquals(1, mongo.getCollection(FindingReviewService.COLLECTION).countDocuments()); + assertTrue(service.history(projectId, "v1", token(), 0).events().isEmpty()); + verifyNoInteractions(projects); + } + @Test void revocationSurvivesPrunedScanAndRetainsOriginalDecision() { + var original = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.scanResult", null), Project.class); + var revoked = service.revoke(projectId, "v1", token(), "second-moderator", original.id(), "New information invalidates the earlier review"); + assertEquals(original.id(), revoked.revokedDecisionId()); + assertEquals("second-moderator", revoked.actorId()); + assertEquals(original, mongo.findById(original.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + assertEquals(List.of(revoked, original), service.history(projectId, "v1", token(), 0).events()); + assertThrows(ResponseStatusException.class, () -> service.revoke(projectId, "v1", token(), "reviewer", original.id(), "Duplicate revocation should fail")); + } + @Test void cannotRevokeAnOrphanOrReadCrossVersionHistory() { + var original = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", null), Project.class); + assertThrows(ResponseStatusException.class, () -> service.revoke(projectId, "v1", token(), "reviewer", original.id(), "An orphan is not active history")); + var foreign = new FindingReviewService.Event("foreign", projectId, "another-version", null, 1, "actor", 0, 0, + FindingReviewService.Disposition.ACCEPT, "foreign decision", "WHOLE_ARTIFACT", null, null, null, null, null, null, null); + mongo.insert(foreign, FindingReviewService.COLLECTION); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "foreign"), Project.class); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), 0)); + } + @Test void historyPagesAreStableAndMissingPredecessorsFailExplicitly() { + var first = record(token()); var previous = first; + for (int i = 2; i <= 51; i++) { + var next = new FindingReviewService.Event("event-" + i, projectId, "v1", previous.id(), i, + first.actorId(), first.createdAt() + i, first.expiresAt(), first.disposition(), first.rationale(), + first.scope(), first.artifactSha256(), first.contentSha256(), first.policyVersion(), + first.contextSha256(), first.finding(), null, null); + mongo.insert(next, FindingReviewService.COLLECTION); previous = next; + } + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", previous.id()), Project.class); + var page = service.history(projectId, "v1", token(), 0); + assertEquals(50, page.events().size()); assertEquals(50, page.nextOffset()); + assertEquals("event-51", page.events().getFirst().id()); + var older = service.history(projectId, "v1", token(), page.nextOffset()); + assertEquals(List.of(first), older.events()); assertNull(older.nextOffset()); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), -1)); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", "event-10")); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), 0)); + assertThrows(ResponseStatusException.class, () -> record(token())); + } + @Test void missingHistoryFailsExplicitlyInsteadOfAppearingEmpty() { + var event = record(token()); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", event.id())); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), 0)); + assertThrows(ResponseStatusException.class, () -> record(token())); + } + @Test void invalidRationaleOrUnboundEvidenceCannotCreateRecords() { + for (String rationale : Arrays.asList(null, "short", "x".repeat(4001))) + assertThrows(ResponseStatusException.class, () -> service.record(projectId, "v1", token(), "reviewer", + new FindingReviewService.Request(0, FindingReviewService.Disposition.ACCEPT, rationale))); + assertThrows(ResponseStatusException.class, () -> service.record(projectId, "v1", token(), "reviewer", + new FindingReviewService.Request(null, FindingReviewService.Disposition.ACCEPT, "No finding was selected"))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.gameVersions", List.of("changed-runtime")), Project.class); + assertThrows(ResponseStatusException.class, () -> record(token())); + assertEquals(0, mongo.getCollection(FindingReviewService.COLLECTION).countDocuments()); + } + private FindingReviewService.Event requireFurtherReview() { + return service.record(projectId, "v1", token(), "moderator", new FindingReviewService.Request(0, + FindingReviewService.Disposition.REQUIRE_REVIEW, "Investigate the destination and its caller")); + } + private boolean approveVersion() { + var current = version(); var snapshot = persistence.capture(projectId, "v1", token()); + current.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + return persistence.apply(snapshot, current); + } + private boolean approveProject() { + var current = mongo.findById(projectId, Project.class); + var projectPersistence = new ProjectReviewPersistence(mongo); + var snapshot = projectPersistence.capture(projectId, ProjectReviewSnapshot.token(current)); + snapshot.project().setStatus(ProjectStatus.PUBLISHED); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + return projectPersistence.apply(snapshot, "v1"); + } + @Test void unresolvedRequirementBlocksBothManualApprovalPathsUntilExplicitResolution() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.PENDING), Project.class); + var requirement = requireFurtherReview(); + var failure = assertThrows(ResponseStatusException.class, this::approveVersion); + assertEquals(409, failure.getStatusCode().value()); + assertTrue(failure.getReason().contains("outstanding finding-review")); + assertThrows(ResponseStatusException.class, this::approveProject); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertEquals(ProjectStatus.PENDING, mongo.findById(projectId, Project.class).getStatus()); + var resolution = record(token()); + assertEquals(requirement.id(), resolution.supersedesDecisionId()); + assertTrue(approveProject()); + assertEquals(resolution.id(), version().getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + } + @Test void requirementsSurviveContextChangesAndPruningUntilExplicitRevocation() { + var requirement = requireFurtherReview(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.scanResult", null).set("versions.0.gameVersions", List.of("new-runtime")), Project.class); + assertThrows(ResponseStatusException.class, this::approveVersion); + service.revoke(projectId, "v1", token(), "moderator", requirement.id(), "Independent inspection resolves this requirement"); + assertTrue(approveVersion()); + } + @Test void missingOrForgedHistoryCannotAuthorizeManualApproval() { + var event = record(token()); + mongo.getCollection(FindingReviewService.COLLECTION).updateOne(new Document("_id", event.id()), + new Document("$set", new Document("supersedesDecisionId", "unreachable-event"))); + assertThrows(ResponseStatusException.class, this::approveVersion); + assertThrows(ResponseStatusException.class, this::approveProject); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", event.id())); + assertThrows(ResponseStatusException.class, this::approveVersion); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void approvalSnapshotCannotSkipANewlyRecordedRequirement() { + record(token()); + var current = version(); var snapshot = persistence.capture(projectId, "v1", token()); + current.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + requireFurtherReview(); + assertFalse(persistence.apply(snapshot, current)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void revokingReplacementDoesNotResurrectEarlierConclusionOrIgnoreNewRequirement() { + requireFurtherReview(); + var resolution = record(token()); + service.revoke(projectId, "v1", token(), "moderator", resolution.id(), "This acceptance needs to be reconsidered"); + // Manual approval is a new decision; revoked acceptance never provides automatic clearance. + assertTrue(approveVersion()); + requireFurtherReview(); + assertThrows(ResponseStatusException.class, this::approveVersion); + } + + @Test void contextEditsInvalidateApprovalButRetainHistoryAndUnknownStoredFields() { + var event = record(token()); assertTrue(approveVersion()); + mongo.getCollection("projects").updateOne(new Document("_id", new org.bson.types.ObjectId(projectId)), + new Document("$set", new Document("versions.0.futureEvidence", new Document("marker", true)))); + var edits = new ProjectReviewPersistence(mongo); + var project = mongo.findById(projectId, Project.class); + var snapshot = edits.capture(projectId, ProjectReviewSnapshot.token(project)); + var edited = snapshot.project().getVersions().getFirst(); + edited.setGameVersions(List.of("new-runtime")); + var queued = new ScanResult(); queued.setScanState("QUEUED"); queued.setScanAttempt(2); edited.setScanResult(queued); + assertTrue(edits.applyVersionEdit(snapshot, "v1", true, false)); + var stored = version(); + assertEquals(ProjectVersion.ReviewStatus.PENDING, stored.getReviewStatus()); + assertEquals(event.id(), stored.getFindingReviewHead()); + assertNull(stored.getApprovedFindingReviewHead()); assertNull(stored.getApprovedSecurityEvidence()); + assertNull(stored.getApprovedReviewOrigins()); assertEquals(0, stored.getSecurityApprovedAt()); + assertEquals(2, stored.getScanResult().getScanAttempt()); + assertEquals(new Document("marker", true), mongo.getCollection("projects").find().first() + .getList("versions", Document.class).getFirst().get("futureEvidence")); + } + @Test void metadataOnlyVersionEditPreservesReviewStateAndHistory() { + var event = record(token()); assertTrue(approveVersion()); + var edits = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = edits.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().getVersions().getFirst().setChangelog("Documentation corrected"); + assertTrue(edits.applyVersionEdit(snapshot, "v1", false, false)); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + assertEquals(event.id(), version().getApprovedFindingReviewHead()); + assertEquals("Documentation corrected", version().getChangelog()); + } + @Test void staleVersionEditCannotRestoreAnApprovalOrEraseNewFindingHistory() { + record(token()); assertTrue(approveVersion()); + var edits = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = edits.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().getVersions().getFirst().setChangelog("Unrelated correction"); + var requirement = requireFurtherReview(); + assertFalse(edits.applyVersionEdit(snapshot, "v1", false, false)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertEquals(requirement.id(), version().getFindingReviewHead()); + } + + @Test void addingAVersionPreservesUnknownSiblingEvidenceAndLinkedApproval() { + var event = record(token()); assertTrue(approveVersion()); + mongo.getCollection("projects").updateOne(new Document(), new Document("$set", new Document("versions.0.futureEvidence", new Document("marker", true)))); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + var added = new ProjectVersion(); added.setId("new-version"); added.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + snapshot.project().getVersions().addFirst(added); + assertTrue(writes.applyVersionList(snapshot)); + var stored = mongo.findById(projectId, Project.class).getVersions(); + assertEquals("new-version", stored.getFirst().getId()); + assertEquals(event.id(), stored.get(1).getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, stored.get(1).getReviewStatus()); + assertEquals(new Document("marker", true), mongo.getCollection("projects").find().first().getList("versions", Document.class).get(1).get("futureEvidence")); + } + @Test void staleVersionDeletionCannotEraseANewerReviewRequirement() { + record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().getVersions().clear(); + var requirement = requireFurtherReview(); + assertFalse(writes.applyVersionList(snapshot)); + assertEquals(requirement.id(), version().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void replacingSomeGameTargetsInvalidatesRetainedApprovalWithoutDeletingHistory() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + var retained = snapshot.project().getVersions().getFirst(); + retained.setGameVersions(List.of("remaining-runtime")); + var queued = new ScanResult(); queued.setScanAttempt(3); queued.setScanState("QUEUED"); retained.setScanResult(queued); + assertTrue(writes.applyVersionList(snapshot)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertNull(version().getApprovedFindingReviewHead()); assertNull(version().getApprovedSecurityEvidence()); + assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(3, version().getScanResult().getScanAttempt()); + assertNotNull(mongo.findById(event.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + } + + @Test void presentationWritesCannotChangeVersionApprovalOrProjectStatus() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var originalStatus = project.getStatus(); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setTitle("Updated title"); snapshot.project().setStatus(ProjectStatus.DELETED); + snapshot.project().getVersions().getFirst().setFindingReviewHead("unrelated"); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); + assertTrue(writes.applyPresentation(snapshot, false)); + var stored = mongo.findById(projectId, Project.class); + assertEquals("Updated title", stored.getTitle()); assertEquals(originalStatus, stored.getStatus()); + assertEquals(event.id(), stored.getVersions().getFirst().getApprovedFindingReviewHead()); + assertEquals(event.id(), stored.getVersions().getFirst().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, stored.getVersions().getFirst().getReviewStatus()); + } + @Test void staleGalleryWriteCannotRestoreOlderApprovalOrDeleteNewHistory() { + record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setGalleryImages(List.of("new-gallery.png")); + var event = requireFurtherReview(); + assertFalse(writes.applyPresentation(snapshot, true)); + assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + + @Test void softDeleteAndRestorePreserveVersionReviewHistory() { + var event = record(token()); + var writes = new ProjectReviewPersistence(mongo); + for (var status : List.of(ProjectStatus.DELETED, ProjectStatus.PUBLISHED)) { + var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(status); + snapshot.project().getVersions().clear(); + assertTrue(writes.applyDeletionState(snapshot, false)); + assertEquals(status, mongo.findById(projectId, Project.class).getStatus()); + assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + } + @Test void staleHardDeleteCannotEraseANewerDecisionButCurrentDeleteRemovesExactlyItsProject() { + record(token()); var writes = new ProjectReviewPersistence(mongo); + var project = mongo.findById(projectId, Project.class); + var stale = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + var event = requireFurtherReview(); + assertFalse(writes.deleteProject(stale)); + assertEquals(event.id(), version().getFindingReviewHead()); + var current = mongo.findById(projectId, Project.class); + assertTrue(writes.deleteProject(writes.capture(projectId, ProjectReviewSnapshot.token(current)))); + assertNull(mongo.findById(projectId, Project.class)); + assertNotNull(mongo.findById(event.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + } + @Test void dependencyScrubDoesNotRewritePreservedVersionEvidence() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setTitle("Deleted Project"); snapshot.project().setImageUrl(null); + snapshot.project().getVersions().getFirst().setApprovedFindingReviewHead("forged"); + assertTrue(writes.applyDeletionState(snapshot, true)); + assertEquals(event.id(), version().getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + } + + @Test void teamUpdatesCannotChangeReviewAuthorityOrPublicationState() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var originalStatus = project.getStatus(); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setAuthorId("new-owner"); snapshot.project().setStatus(ProjectStatus.DELETED); + snapshot.project().setTeamMembers(List.of(new Project.ProjectMember("contributor", "role"))); + snapshot.project().getVersions().clear(); + assertTrue(writes.applyTeam(snapshot)); + var stored = mongo.findById(projectId, Project.class); + assertEquals("new-owner", stored.getAuthorId()); assertEquals(originalStatus, stored.getStatus()); + assertEquals(event.id(), version().getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + } + @Test void staleTeamUpdateCannotEraseANewerRequirementOrConcurrentOwnershipChange() { + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setPendingTransferTo("requested-owner"); + var event = requireFurtherReview(); + assertFalse(writes.applyTeam(snapshot)); assertEquals(event.id(), version().getFindingReviewHead()); + project = mongo.findById(projectId, Project.class); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setPendingTransferTo("requested-owner"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "different-owner"), Project.class); + assertFalse(writes.applyTeam(snapshot)); + assertEquals("different-owner", mongo.findById(projectId, Project.class).getAuthorId()); + } + + @Test void draftSubmissionPreservesCurrentEvidenceAndUnknownFields() { + var event = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.DRAFT), Project.class); + mongo.getCollection("projects").updateOne(new Document(), new Document("$set", new Document("versions.0.futureEvidence", "preserved"))); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PENDING); + snapshot.project().getVersions().getFirst().setScanResult(null); + assertTrue(writes.submitDraft(snapshot)); + assertNotNull(version().getScanResult()); assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals("preserved", mongo.getCollection("projects").find().first().getList("versions", Document.class).getFirst().get("futureEvidence")); + assertFalse(writes.submitDraft(snapshot)); + } + @Test void draftSubmissionQueuesOnlyUnscannedVersionsAndLosesToConcurrentDecision() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.DRAFT).set("versions.0.scanResult", null), Project.class); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PENDING); + var queued = new ScanResult(); queued.setScanState("QUEUED"); queued.setScanAttempt(1); + snapshot.project().getVersions().getFirst().setScanResult(queued); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "new-decision"), Project.class); + assertFalse(writes.submitDraft(snapshot)); assertNull(version().getScanResult()); + project = mongo.findById(projectId, Project.class); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PENDING); snapshot.project().getVersions().getFirst().setScanResult(queued); + assertTrue(writes.submitDraft(snapshot)); assertEquals(1, version().getScanResult().getScanAttempt()); + assertEquals("new-decision", version().getFindingReviewHead()); + } + + @Test void unlistingPreservesEvidenceAndRejectsStaleState() { + var event = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.PUBLISHED), Project.class); + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setVersions(List.of()); + assertTrue(writes.unlist(snapshot)); + assertEquals(ProjectStatus.UNLISTED, mongo.findById(projectId, Project.class).getStatus()); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + assertFalse(writes.unlist(snapshot)); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "new-head"), Project.class); + assertFalse(writes.unlist(snapshot)); assertEquals("new-head", version().getFindingReviewHead()); + } + @Test void unlistingCannotPublishAnUnreviewedOrDeletedProject() { + var writes = new ProjectReviewPersistence(mongo); + for (var status : List.of(ProjectStatus.DRAFT, ProjectStatus.PENDING, ProjectStatus.PRIVATE, ProjectStatus.ARCHIVED, ProjectStatus.DELETED)) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", status), Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(ResponseStatusException.class, () -> writes.unlist(snapshot)); + assertEquals(status, mongo.findById(projectId, Project.class).getStatus()); + } + } + + @Test void commentUpdatesCannotChangeReviewStateOrOverwriteNewDecisions() { + var event = record(token()); var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setComments(List.of(new Comment("user", "hello"))); + var originalStatus = snapshot.project().getStatus(); + snapshot.project().setStatus(ProjectStatus.DELETED); snapshot.project().setVersions(List.of()); + assertTrue(writes.applyComments(snapshot)); + var stored = mongo.findById(projectId, Project.class); + assertEquals(originalStatus, stored.getStatus()); + assertEquals("hello", stored.getComments().getFirst().getContent()); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(stored)); + snapshot.project().getComments().getFirst().setPinned(true); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "later"), Project.class); + assertFalse(writes.applyComments(snapshot)); + assertFalse(mongo.findById(projectId, Project.class).getComments().getFirst().isPinned()); + assertEquals("later", version().getFindingReviewHead()); + } + @Test void commentUpdatesCannotUndoConcurrentCommentPolicyOrEdits() { + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setComments(List.of(new Comment("user", "stale"))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), + new Update().set("allowComments", false).set("comments", List.of(new Comment("other", "current"))), Project.class); + assertFalse(writes.applyComments(snapshot)); + var stored = mongo.findById(projectId, Project.class); + assertFalse(stored.isAllowComments()); assertEquals("current", stored.getComments().getFirst().getContent()); + } + + @Test void archiveCacheChangesOnlyTheSelectedFileReferenceAndRejectsStaleGeneration() { + var event = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("classification", ProjectClassification.MODPACK), Project.class); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + String projectToken = ProjectReviewSnapshot.token(project), versionToken = token(); + assertTrue(writes.cacheModpackArchive(projectId, projectToken, "v1", versionToken, "modpacks/new.zip")); + assertEquals("modpacks/new.zip", version().getFileUrl()); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, projectToken, "v1", versionToken, "modpacks/stale.zip")); + project = mongo.findById(projectId, Project.class); + String currentProjectToken = ProjectReviewSnapshot.token(project), currentVersionToken = token(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "later"), Project.class); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, currentProjectToken, "v1", currentVersionToken, "modpacks/stale.zip")); + assertEquals("later", version().getFindingReviewHead()); assertEquals("modpacks/new.zip", version().getFileUrl()); + } + @Test void archiveCacheRejectsForeignVersionsAndNonModpacks() { + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + String original = ProjectReviewSnapshot.token(project); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, original, "v1", token(), "modpacks/new.zip")); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("classification", ProjectClassification.MODPACK), Project.class); + String current = ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class)); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, current, "foreign", token(), "modpacks/new.zip")); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, current, "v1", "wrong", "modpacks/new.zip")); + assertFalse(writes.cacheModpackArchive(projectId, current, "v1", token(), null)); + assertNull(version().getFileUrl()); + } + + @Test void staleTransferNotificationsCannotCancelReplacementRequests() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "new-request").set("pendingTransferOwnerId", "owner") + .set("pendingTransferExpiresAt", System.currentTimeMillis() + 60000), Project.class); + var repository = mock(net.modtale.repository.user.NotificationRepository.class); + var notifications = new net.modtale.service.communication.NotificationService(repository, + mock(net.modtale.repository.user.UserRepository.class), mongo, + mock(net.modtale.service.communication.NotificationDeliveryService.class)); + var otherRecipient = new net.modtale.model.user.Notification("other-recipient", "Request", "Request", java.net.URI.create("/dashboard"), null, + net.modtale.model.user.NotificationType.TRANSFER_REQUEST, + Map.of("projectId", projectId, "requestId", "new-request", "targetUserId", "recipient")); + otherRecipient.setId("other-notification"); when(repository.findById("other-notification")).thenReturn(Optional.of(otherRecipient)); + notifications.deleteNotification("other-notification", "other-recipient"); + assertEquals("recipient", mongo.findById(projectId, Project.class).getPendingTransferTo()); + for (String requestId : List.of("", "old-request", "new-request")) { + var metadata = new HashMap(); metadata.put("projectId", projectId); + metadata.put("targetUserId", "recipient"); if (!requestId.isEmpty()) metadata.put("requestId", requestId); + var notification = new net.modtale.model.user.Notification("recipient", "Request", "Request", java.net.URI.create("/dashboard"), null, + net.modtale.model.user.NotificationType.TRANSFER_REQUEST, metadata); notification.setId("notification"); + when(repository.findById("notification")).thenReturn(Optional.of(notification)); + notifications.deleteNotification("notification", "recipient"); + var current = mongo.findById(projectId, Project.class); + if (requestId.equals("new-request")) assertNull(current.getPendingTransferTo()); + else assertEquals("recipient", current.getPendingTransferTo()); + } + assertNotNull(version().getScanResult()); + } + @Test void expiredTransferCleanupWorksWithoutAnyNotificationAndKeepsNewRequests() { + var notifications = new net.modtale.service.communication.NotificationService(mock(net.modtale.repository.user.NotificationRepository.class), + mock(net.modtale.repository.user.UserRepository.class), mongo, mock(net.modtale.service.communication.NotificationDeliveryService.class)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "expired").set("pendingTransferExpiresAt", 1L), Project.class); + notifications.cleanupExpiredTransfers(); assertNull(mongo.findById(projectId, Project.class).getPendingTransferTo()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "new").set("pendingTransferExpiresAt", System.currentTimeMillis()+60000), Project.class); + notifications.cleanupExpiredTransfers(); assertEquals("new", mongo.findById(projectId, Project.class).getPendingTransferRequestId()); + } + @Test void finalTransferWriteChecksDatabaseExpiryAndOriginalOwner() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "owner") + .set("pendingTransferOwnerId", "owner").set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "request").set("pendingTransferExpiresAt", 1L), Project.class); + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setAuthorId("recipient"); snapshot.project().setPendingTransferTo(null); + assertFalse(writes.resolveTransfer(snapshot, "request")); assertEquals("owner", mongo.findById(projectId, Project.class).getAuthorId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferExpiresAt", System.currentTimeMillis()+60000), Project.class); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setAuthorId("recipient"); snapshot.project().setPendingTransferTo(null); + assertTrue(writes.resolveTransfer(snapshot, "request")); + assertEquals("recipient", mongo.findById(projectId, Project.class).getAuthorId()); assertNotNull(version().getScanResult()); + } + + private void prepareContributorInvite(String requestId, long expiry) { + var invite = new Project.ProjectMember("recipient", "role"); invite.setRequestId(requestId); + invite.setRequestOwnerId("owner"); invite.setRequestExpiresAt(expiry); + invite.setRequestPermissions(Set.of(net.modtale.model.user.ApiKey.ApiPermission.PROJECT_EDIT_METADATA)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "owner") + .set("teamInvites", List.of(invite)).set("projectRoles", List.of(new Project.ProjectRole("role", "Role", "#fff", invite.getRequestPermissions()))), Project.class); + } + @Test void contributorAcceptanceRetainsEvidenceAndBindsTheOfferedRole() { + var event = record(token()); prepareContributorInvite("invite", System.currentTimeMillis()+60000); + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setTeamInvites(List.of()); snapshot.project().setTeamMembers(List.of(new Project.ProjectMember("recipient", "role"))); + assertTrue(writes.resolveContributorInvite(snapshot, "recipient", "invite", true)); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + prepareContributorInvite("invite2", System.currentTimeMillis()+60000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("projectRoles.0.permissions", List.of()), Project.class); + var changed = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(changed, "recipient", "invite2", true)); + } + @Test void staleContributorCancellationAndLegacyIdentityCannotRemoveReplacement() { + prepareContributorInvite("old", System.currentTimeMillis()+60000); var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setTeamInvites(List.of()); prepareContributorInvite("new", System.currentTimeMillis()+60000); + assertFalse(writes.resolveContributorInvite(snapshot, "recipient", "old", false)); + var current = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(current, "recipient", "legacy", false)); + prepareContributorInvite(null, 0); + var legacy = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + legacy.project().setTeamInvites(List.of()); assertTrue(writes.resolveContributorInvite(legacy, "recipient", "legacy", false)); + } + @Test void contributorNotificationsAndIndependentExpiryKeepCurrentInvitations() { + prepareContributorInvite("new", System.currentTimeMillis()+60000); + var repository = mock(net.modtale.repository.user.NotificationRepository.class); + var notifications = new net.modtale.service.communication.NotificationService(repository, + mock(net.modtale.repository.user.UserRepository.class), mongo, mock(net.modtale.service.communication.NotificationDeliveryService.class)); + var old = new net.modtale.model.user.Notification("recipient", "Invite", "Invite", java.net.URI.create("/dashboard"), null, + net.modtale.model.user.NotificationType.CONTRIBUTOR_INVITE, Map.of("projectId", projectId, "requestId", "old")); + old.setId("old"); when(repository.findById("old")).thenReturn(Optional.of(old)); notifications.deleteNotification("old", "recipient"); + notifications.cleanupExpiredContributorInvites(); + assertEquals("new", mongo.findById(projectId, Project.class).getTeamInvites().getFirst().getRequestId()); + prepareContributorInvite("expired", 1); notifications.cleanupExpiredContributorInvites(); + assertTrue(mongo.findById(projectId, Project.class).getTeamInvites().isEmpty()); + } + @Test void expiredOrOwnerChangedContributorInvitesCannotGrantMembership() { + var writes = new ProjectReviewPersistence(mongo); prepareContributorInvite("invite", 1); + var expired = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(expired, "recipient", "invite", true)); + prepareContributorInvite("invite", System.currentTimeMillis()+60000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "different"), Project.class); + var changed = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(changed, "recipient", "invite", true)); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java new file mode 100644 index 000000000..1f87da69e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java @@ -0,0 +1,30 @@ +package net.modtale.service.admin.review; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import static org.junit.jupiter.api.Assertions.*; + +class ModerationQueueCursorTest { + @Test void typedIdentityAndUnicodeRoundTripWithoutStringObjectIdAliasing() { + var objectId=new ObjectId(); + for(Object id:new Object[]{objectId,objectId.toHexString(),"😀".repeat(128),"a\u0000b"}) { + var cursor=new ModerationQueuePageReader.Cursor(id,16777216); + assertEquals(cursor,ModerationQueueCursor.decode(ModerationQueueCursor.encode(cursor))); + } + assertNotEquals(ModerationQueueCursor.encode(new ModerationQueuePageReader.Cursor(objectId,0)),ModerationQueueCursor.encode(new ModerationQueuePageReader.Cursor(objectId.toHexString(),0))); + assertNull(ModerationQueueCursor.decode(null));assertNull(ModerationQueueCursor.encode(null)); + } + @Test void filteredCursorPreservesScope() { + for(var filter:ModerationQueuePageReader.Filter.values()) { + var value=new ModerationQueuePageReader.Cursor("project",3,filter); + assertEquals(value,ModerationQueueCursor.decode(ModerationQueueCursor.encode(value))); + } + assertThrows(IllegalArgumentException.class,()->ModerationQueueCursor.decode("2.ALL.s.0.YQ")); + } + @Test void noncanonicalOversizedInvalidUtf8AndOutOfRangeCursorsAreRejected() { + for(String token:new String[]{"","2.s.0.YQ","1.s.-1.YQ","1.s.00.YQ","1.s.16777217.YQ","1.s.0.YQ==","1.s.0.YR","1.s.0._w","1.o.0.YQ","x".repeat(801),"1.s.0."+Base64.getUrlEncoder().withoutPadding().encodeToString("x".repeat(129).getBytes(StandardCharsets.UTF_8))}) + assertThrows(IllegalArgumentException.class,()->ModerationQueueCursor.decode(token)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java new file mode 100644 index 000000000..5f277857e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java @@ -0,0 +1,91 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ModerationQueuePageReaderTest { + MongoClient client;MongoDatabase db;MongoCollection projects;ModerationQueuePageReader reader; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_queue_page_"+UUID.randomUUID().toString().replace("-",""));projects=db.getCollection("projects"); + reader=new ModerationQueuePageReader(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document version(String id,String status) {return new Document("_id",id).append("reviewStatus","PENDING").append("versionNumber",id) + .append("scanResult",new Document("status",status).append("scanState","REMOTE_HELD").append("riskScore",0));} + void insert(Object id,String status,List versions) {projects.insertOne(new Document("_id",id).append("status",status).append("title","title").append("versions",versions));} + @Test void boundedPagesVisitVersionsAcrossMixedProjectIds() { + var versions=new ArrayList();for(int i=0;i<71;i++)versions.add(version("v"+i,"FAILED"));insert("string","PUBLISHED",versions); + var id=new ObjectId();insert(id,"PUBLISHED",List.of(version("last","SUSPICIOUS"))); + ModerationQueuePageReader.Cursor cursor=null;var ids=new HashSet();int count=0; + do {var page=reader.page(cursor,7);assertTrue(page.items().size()<=7);assertEquals(0,page.unavailableItems()); + for(var row:page.items()){assertTrue(ids.add(row.id()+":"+row.pendingVersion().id()));count++;}cursor=page.next();assertTrue(count<=72); + } while(cursor!=null); + assertEquals(72,count);assertTrue(ids.contains(id.toHexString()+":last")); + } + @Test void scanningSiblingsAreSkippedAndProjectOnlyRowsRemain() { + insert("a","PUBLISHED",List.of(version("scan","SCANNING"),version("failed","FAILED"))); + insert("b","PENDING",List.of());insert("c","PENDING",List.of(version("approved","CLEAN").append("reviewStatus","APPROVED"))); + insert("d","DRAFT",List.of(version("hidden","FAILED"))); + var page=reader.page(null,50);assertEquals(3,page.items().size());assertEquals("failed",page.items().getFirst().pendingVersion().id()); + assertNull(page.items().get(1).pendingVersion());assertNull(page.items().get(2).pendingVersion());assertNull(page.next()); + } + @Test void malformedAndDuplicateVersionIdsAreCountedWithoutLosingContinuation() { + insert("a","PUBLISHED",List.of(version("duplicate","FAILED"),version("duplicate","FAILED"),version("x".repeat(200),"FAILED"),version("valid","FAILED"))); + var first=reader.page(null,2);assertTrue(first.items().isEmpty());assertEquals(2,first.unavailableItems());assertNotNull(first.next()); + var second=reader.page(first.next(),2);assertEquals(1,second.unavailableItems());assertEquals("valid",second.items().getFirst().pendingVersion().id());assertNull(second.next()); + } + @Test void displayFieldsAreBoundedAndEvidenceIsNotReturned() { + var value=version("v","FAILED").append("changelog","x".repeat(10000));value.get("scanResult",Document.class).append("issues",List.of(new Document("private","body"))); + insert("a","PUBLISHED",List.of(value));projects.updateOne(new Document("_id","a"),new Document("$set",new Document("title","t".repeat(10000)))); + var row=reader.page(null,1).items().getFirst();assertEquals(256,row.title().length());assertEquals(1024,row.pendingVersion().changelog().length()); + assertFalse(row.toString().contains("private"));assertEquals("REMOTE_HELD",row.pendingVersion().scan().scanState()); + } + @Test void deletedCursorProjectContinuesAtNextRoot() { + insert("a","PUBLISHED",List.of(version("one","FAILED"),version("two","FAILED")));insert("b","PUBLISHED",List.of(version("next","FAILED"))); + var page=reader.page(null,1);projects.deleteOne(new Document("_id","a")); + assertEquals("next",reader.page(page.next(),1).items().getFirst().pendingVersion().id()); + } + @Test void httpCursorRoundTripContinuesActualDatabasePage()throws Exception { + insert("a","PUBLISHED",List.of(version("one","FAILED"),version("two","FAILED"))); + var controller=new net.modtale.controller.admin.ModerationQueuePageController(reader); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var first=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/queue/page").param("limit","1")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()).andReturn(); + var body=new com.fasterxml.jackson.databind.ObjectMapper().readTree(first.getResponse().getContentAsString()); + assertEquals("one",body.get("items").get(0).get("pendingVersion").get("id").asText()); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/queue/page").param("limit","1").param("cursor",body.get("nextCursor").asText())) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].pendingVersion.id").value("two")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.nextCursor").isEmpty()); + } + @Test void filtersApplyBeforeLimitsAndRetainOverlappingSecurityFailures() { + var versions=new ArrayList();for(int i=0;i<60;i++)versions.add(version("ordinary"+i,"CLEAN")); + versions.add(version("security","SUSPICIOUS"));versions.add(version("failure","FAILED")); + var both=version("both","FAILED");both.get("scanResult",Document.class).put("newIssueCount",1);versions.add(both); + insert("a","PUBLISHED",versions); + var first=reader.page(null,1,ModerationQueuePageReader.Filter.SECURITY);assertEquals("security",first.items().getFirst().pendingVersion().id()); + assertEquals(ModerationQueuePageReader.Filter.SECURITY,first.next().filter()); + assertEquals("both",reader.page(first.next(),1,ModerationQueuePageReader.Filter.SECURITY).items().getFirst().pendingVersion().id()); + var failed=reader.page(null,50,ModerationQueuePageReader.Filter.OPERATIONS); + assertEquals(List.of("failure","both"),failed.items().stream().map(row->row.pendingVersion().id()).toList()); + assertThrows(IllegalArgumentException.class,()->reader.page(first.next(),1,ModerationQueuePageReader.Filter.OPERATIONS)); + } + @Test void knownCountsAloneDoNotPutCleanResultsInSecurityFilter() { + var known=version("vetted","CLEAN");known.get("scanResult",Document.class).put("knownIssueCount",8);insert("a","PUBLISHED",List.of(known)); + assertTrue(reader.page(null,50,ModerationQueuePageReader.Filter.SECURITY).items().isEmpty()); + assertEquals(1,reader.page(null,50).items().size()); + } + @Test void invalidLimitsAndCursorAreRejectedBeforeRead() { + assertThrows(IllegalArgumentException.class,()->reader.page(null,0));assertThrows(IllegalArgumentException.class,()->reader.page(null,51)); + assertThrows(IllegalArgumentException.class,()->new ModerationQueuePageReader.Cursor(42,0)); + assertThrows(IllegalArgumentException.class,()->new ModerationQueuePageReader.Cursor("a",-1)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java new file mode 100644 index 000000000..4485d1016 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java @@ -0,0 +1,24 @@ +package net.modtale.service.admin.review; + +import java.util.*; +import net.modtale.service.admin.project.ProjectMetadataRepair; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectMetadataRepairTest { + @Test void acceptsMetadataRepairsWithoutAcceptingAuthorityFields() { + assertDoesNotThrow(() -> ProjectMetadataRepair.validate(Map.of("title", "Repaired title", "tags", List.of("tools"), + "links", Map.of("source", "https://example.com"), "allowComments", true))); + for (String field : List.of("versions", "status", "approvedBy", "authorId", "projectRoles", "teamMembers", + "classification", "childProjectIds", "reviewToken", "securityApprovedAt", "versions.0.reviewStatus", "$set")) + assertThrows(ResponseStatusException.class, () -> ProjectMetadataRepair.validate(Map.of(field, "forged")), field); + } + @Test void rejectsMalformedAndOversizedMetadata() { + for (Map value : List.>of(Map.of(), Map.of("title", " "), + Map.of("allowComments", "true"), Map.of("tags", List.of(Map.of("status", "APPROVED"))), + Map.of("links", Map.of("source", List.of("bad"))), Map.of("description", "x".repeat(100_001)), + Map.of("tags", Collections.nCopies(1001, "x")), Map.of("tags", Collections.nCopies(100, "x".repeat(4096))))) + assertThrows(ResponseStatusException.class, () -> ProjectMetadataRepair.validate(value)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerTest.java new file mode 100644 index 000000000..85952cbb1 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerTest.java @@ -0,0 +1,43 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewScheduler; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProjectMutationAdmissionSchedulerTest { + ProjectMutationDiscovery discovery=mock(ProjectMutationDiscovery.class);ProjectMutationAutomaticAdmission automatic=mock(ProjectMutationAutomaticAdmission.class); + ProjectMutationAdmissionScheduler scheduler(long drain){return new ProjectMutationAdmissionScheduler(discovery,automatic,new ProjectMutationAdmissionScheduler.Settings(1,4,100,drain));} + ProjectMutationDiscovery.Candidate candidate(){return new ProjectMutationDiscovery.Candidate("p",0,"v",UUID.randomUUID().toString(),UUID.randomUUID().toString(),1);} + void empty(){when(discovery.page(any(),anyInt())).thenReturn(new ProjectMutationDiscovery.Page(List.of(),null,0,0));} + void until(BooleanSupplier ready)throws Exception{long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(3);while(!ready.getAsBoolean()&&System.nanoTime(){handled.countDown();return new ProjectMutationAutomaticAdmission.Result("ADMITTED","decision");}); + try(var scheduler=scheduler(1000)){scheduler.start();assertTrue(handled.await(3,TimeUnit.SECONDS));until(()->scheduler.status().processed()==1);assertEquals(1,scheduler.unavailableCandidates());verify(discovery).page(cursor,4);assertEquals("ADMITTED",scheduler.status().lastOutcome());} + } + @Test void stopWaitsForActualAdmissionReturnBeforeCallback()throws Exception { + var candidate=candidate();var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(1);var guard=new AtomicReference(); + when(discovery.page(any(),anyInt())).thenReturn(new ProjectMutationDiscovery.Page(List.of(candidate),null,1,0)); + when(automatic.advance(any(),any())).thenAnswer(call->{guard.set(call.getArgument(1));entered.countDown();long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(5);while(release.getCount()!=0&&System.nanoTime()"DRAIN_TIMEOUT".equals(scheduler.status().state()));assertEquals(1,done.getCount());assertFalse(guard.get().getAsBoolean());assertThrows(IllegalStateException.class,scheduler::close);}finally{release.countDown();} + assertTrue(done.await(3,TimeUnit.SECONDS));scheduler.close();assertEquals("STOPPED",scheduler.status().state()); + } + @Test void schedulerRequiresExplicitFlagDependenciesAndStopsWithContext(){ + new ApplicationContextRunner().withUserConfiguration(ProjectMutationAdmissionSchedulerConfiguration.class).run(context->assertFalse(context.containsBean("projectMutationAdmissionScheduler"))); + empty();var recovery=mock(ProjectMutationAttemptRecovery.class);when(recovery.page(any(),anyInt())).thenReturn(new ProjectMutationAttemptRecovery.Page(List.of(),null,0,0));var runner=new ApplicationContextRunner().withUserConfiguration(ProjectMutationAdmissionSchedulerConfiguration.class).withBean(ProjectMutationDiscovery.class,()->discovery) + .withBean(ProjectMutationAttemptRecovery.class,()->recovery).withBean(ProjectMutationAutomaticAdmission.class,()->automatic).withPropertyValues("app.warden.repair.admission.scheduler.enabled=true"); + runner.run(context->assertNotNull(context.getStartupFailure()));var delivery=mock(RemoteReviewScheduler.class); + runner.withBean(RemoteReviewScheduler.class,()->delivery).run(context->assertNotNull(context.getStartupFailure())); + var recovered=new AtomicReference();var reference=new AtomicReference();runner.withBean(RemoteReviewScheduler.class,()->delivery) + .withPropertyValues("app.warden.repair.enabled=true","app.warden.jobs.enabled=true").run(context->{assertNull(context.getStartupFailure());reference.set(context.getBean(ProjectMutationAdmissionScheduler.class));assertTrue(reference.get().isRunning());recovered.set(context.getBean(ProjectMutationRecoveryScheduler.class));assertTrue(recovered.get().isRunning());}); + assertEquals("STOPPED",reference.get().status().state());assertEquals("STOPPED",recovered.get().status().state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationPermissionsTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationPermissionsTest.java new file mode 100644 index 000000000..52c78086b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationPermissionsTest.java @@ -0,0 +1,29 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.ApiKey.ApiPermission; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectMutationPermissionsTest { + Document root(){return new Document("_id","p").append("status","DRAFT").append("versions",List.of(new Document("_id","v"),new Document("_id","w")));} + Set required(Document before,Document after,ProjectMutationPreparation.Mutation kind){return ProjectMutationPreparation.requiredPermissions(bytes(before),bytes(after),"p",kind);} + static byte[] bytes(Document doc){var b=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();var data=new byte[b.remaining()];b.get(data);return data;} + @Test void pureReorderingRequiresEditButShiftFromRemovalDoesNot(){ + var before=root();var after=root();after.put("versions",List.of(new Document("_id","w"),new Document("_id","v"))); + assertEquals(Set.of(ApiPermission.VERSION_EDIT),required(before,after,ProjectMutationPreparation.Mutation.VERSION_LIST)); + after.put("versions",List.of(new Document("_id","w")));assertEquals(Set.of(ApiPermission.VERSION_DELETE),required(before,after,ProjectMutationPreparation.Mutation.VERSION_LIST)); + } + @Test void submissionDoesNotAuthorizeAdditionalContextChanges(){ + var before=root();var after=root();after.put("status","PENDING"); + assertEquals(Set.of(ApiPermission.PROJECT_STATUS_SUBMIT),required(before,after,ProjectMutationPreparation.Mutation.SUBMISSION)); + after.getList("versions",Document.class).getFirst().put("gameVersions",List.of("changed")); + assertEquals(Set.of(ApiPermission.PROJECT_STATUS_SUBMIT,ApiPermission.VERSION_EDIT),required(before,after,ProjectMutationPreparation.Mutation.SUBMISSION)); + } + @Test void rootAuthorityChangesAndNoOpCannotObtainMutationPermissions(){ + var before=root();var after=root();assertThrows(RuntimeException.class,()->required(before,after,ProjectMutationPreparation.Mutation.VERSION_LIST)); + after.put("authorId","other");assertThrows(RuntimeException.class,()->required(before,after,ProjectMutationPreparation.Mutation.VERSION_LIST)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationProgressTestSupport.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationProgressTestSupport.java new file mode 100644 index 000000000..af9d26b94 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationProgressTestSupport.java @@ -0,0 +1,46 @@ +package net.modtale.service.admin.review; + +import org.springframework.data.mongodb.core.MongoTemplate; +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.Document; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Seeds prior successful observation checkpoints through their real validation/write path. */ +public final class ProjectMutationProgressTestSupport { + private ProjectMutationProgressTestSupport() {} + public static void retain(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationJobAccounting accounting, + ProjectMutationAdmissionAttempts.Scope scope,ProjectMutationPriorWorkReader.Work work,ProjectMutationJobAccounting.Receipt receipt) { + budget.call(allowed->{new ProjectMutationObservationProgress(mongo,accounting).retain(scope,work,receipt,allowed);return null;},()->true); + } + public static void assertReadBatchBoundaries(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationReferenceReader history, + RemoteReviewClient client,ProjectMutationAdmissionAttempts.Scope scope,Map ids)throws Exception { + var verified=spy(history);var permission=new AtomicBoolean(true); + try(var accounting=new ProjectMutationJobAccounting(mongo,budget,verified,client,1)){ + budget.call(allowed->{ + var batch=accounting.readBatch(scope.projectId(),()->allowed.getAsBoolean()&&permission.get()); + try(batch){ + for(var entry:ids.entrySet())assertEquals("OBSERVED",batch.receipt(entry.getValue(),scope.projectId(),scope.mutationId(),entry.getKey()).state()); + verify(verified,times(1)).read(eq(scope.projectId()),eq(scope.mutationId()),any()); + var first=ids.entrySet().iterator().next(); + assertThrows(IllegalStateException.class,()->batch.receipt(first.getValue(),scope.projectId().toString(),scope.mutationId(),first.getKey())); + permission.set(false);assertThrows(SecurityException.class,()->batch.receipt(first.getValue(),scope.projectId(),scope.mutationId(),first.getKey()));permission.set(true); + try(var worker=Executors.newVirtualThreadPerTaskExecutor()){ + var task=worker.submit(()->batch.receipt(first.getValue(),scope.projectId(),scope.mutationId(),first.getKey())); + var failure=assertThrows(ExecutionException.class,()->task.get(3,TimeUnit.SECONDS));assertInstanceOf(IllegalStateException.class,failure.getCause()); + } + var row=mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).find(new Document("_id",first.getValue())).first(); + mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).updateOne(new Document("_id",first.getValue()),new Document("$set",new Document("intent.beforeSha256","0".repeat(64)))); + assertThrows(IllegalStateException.class,()->batch.receipt(first.getValue(),scope.projectId(),scope.mutationId(),first.getKey())); + mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).replaceOne(new Document("_id",first.getValue()),row); + } + var first=ids.entrySet().iterator().next();assertThrows(IllegalStateException.class,()->batch.receipt(first.getValue(),scope.projectId(),scope.mutationId(),first.getKey()));return null; + },()->true); + mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",scope.mutationId()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->budget.call(allowed->{try(var batch=accounting.readBatch(scope.projectId(),allowed)){var first=ids.entrySet().iterator().next();return batch.receipt(first.getValue(),scope.projectId(),scope.mutationId(),first.getKey());}},()->true)); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationWorkflowTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationWorkflowTest.java new file mode 100644 index 000000000..ed1a315d6 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectMutationWorkflowTest.java @@ -0,0 +1,45 @@ +package net.modtale.service.admin.review; + +import org.junit.jupiter.api.Test; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProjectMutationWorkflowTest { + final ProjectMutationPreparation preparation=mock(ProjectMutationPreparation.class); + final ProjectMutationExecutor executor=mock(ProjectMutationExecutor.class); + final ProjectMutationReferenceReader history=mock(ProjectMutationReferenceReader.class); + final AtomicLong time=new AtomicLong(); + final ReviewRepairWorkflow budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1,1000,time::get); + final ProjectMutationWorkflow workflow=new ProjectMutationWorkflow(budget,preparation,executor,history); + + @Test void allOperationsShareExistingAdmissionAndRejectBusyWithoutIo(){ + budget.read(()->{ + assertThrows(IllegalStateException.class,()->workflow.capture("p",()->true)); + assertThrows(IllegalStateException.class,()->workflow.prepare(null,()->true)); + assertThrows(IllegalStateException.class,()->workflow.apply(null,"owner",()->true)); + assertThrows(IllegalStateException.class,()->workflow.recover("id","owner",()->true)); + assertThrows(IllegalStateException.class,()->workflow.receipt(null,"owner",()->true)); + assertThrows(IllegalStateException.class,()->workflow.page("p",null,1,()->true)); + assertThrows(IllegalStateException.class,()->workflow.history("p","id",()->true)); + assertEquals(1,budget.status().active());return null; + },()->true); + assertEquals(0,budget.status().active());verifyNoInteractions(preparation,executor,history); + } + @Test void deadlineReachesInnerPermissionAndReleasesSlot(){ + doAnswer(call->{assertEquals(1,budget.status().active());time.set(1_000_000_001L); + assertThrows(IllegalStateException.class,()->((BooleanSupplier)call.getArgument(1)).getAsBoolean());return null; + }).when(preparation).capture(eq("p"),any()); + assertThrows(IllegalStateException.class,()->workflow.capture("p",()->true));assertEquals(0,budget.status().active()); + } + @Test void permissionLossDuringReadDoesNotReturnResult(){ + var allowed=new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call->{allowed.set(false);assertFalse(((BooleanSupplier)call.getArgument(1)).getAsBoolean());return null;}).when(preparation).capture(eq("p"),any()); + assertThrows(SecurityException.class,()->workflow.capture("p",allowed::get));assertEquals(0,budget.status().active()); + } + @Test void sharedShutdownPreventsMutationAndHistoryWork(){ + budget.close();assertThrows(IllegalStateException.class,()->workflow.capture("p",()->true)); + assertThrows(IllegalStateException.class,()->workflow.history("p","id",()->true));verifyNoInteractions(preparation,executor,history); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java index 3cf56f001..71535f5f9 100644 --- a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java @@ -54,13 +54,16 @@ void setUp() { projectVersionAccessService = mock(ProjectVersionAccessService.class); adminAuditLogger = mock(AdminAuditLogger.class); + var persistence=mock(VersionReviewPersistence.class); + when(persistence.apply(any(),any())).thenReturn(true); ProjectReviewTransitionService transitionService = new ProjectReviewTransitionService( - projectRepository, projectService, lifecycleService, mock(ScoringService.class), securityIssueAnalysisService, - projectVersionAccessService + projectVersionAccessService, + persistence, + mock(ProjectReviewPersistence.class) ); ProjectReviewEffectService effectService = new ProjectReviewEffectService( userRepository, @@ -83,15 +86,14 @@ void approveVersionUpdatesStateBroadcastsAndAudits() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(projectVersionAccessService.requireById(eq(project), eq("version-1"), any())).thenReturn(version); - service.approveVersion(admin, "project-1", "version-1"); + service.approveVersion(admin, "project-1", "version-1", VersionReviewSnapshot.token(version)); assertEquals(ProjectVersion.ReviewStatus.APPROVED, version.getReviewStatus()); assertNull(version.getRejectionReason()); assertNull(version.getScheduledPublishDate()); assertNotNull(project.getUpdatedAt()); - verify(securityIssueAnalysisService).pruneApprovedScanResults(project); - verify(projectRepository).save(project); + verify(securityIssueAnalysisService).markIssuesAcceptedForApprovedVersion(version); verify(projectService).evictProjectCache(project); verify(projectNotificationService).notifyUpdates(project, "1.0.0"); verify(projectNotificationService).notifyDependents(project, "1.0.0"); @@ -111,13 +113,13 @@ void rejectVersionNotifiesAuthorAndAudits() { when(projectVersionAccessService.requireById(eq(project), eq("version-1"), any())).thenReturn(version); when(userRepository.findById("author-1")).thenReturn(Optional.of(author)); - service.rejectVersion(admin, "project-1", "version-1", "Missing metadata"); + service.rejectVersion(admin, "project-1", "version-1", "Missing metadata", VersionReviewSnapshot.token(version)); assertEquals(ProjectVersion.ReviewStatus.REJECTED, version.getReviewStatus()); assertEquals("Missing metadata", version.getRejectionReason()); assertNull(version.getScheduledPublishDate()); - verify(projectRepository).save(project); + verify(projectService).evictProjectCache(project); verify(projectService).evictProjectCache(project); verify(notificationService).sendNotifcation( java.util.List.of("author-1"), diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java deleted file mode 100644 index 9f3272f22..000000000 --- a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.modtale.service.admin.review; - -import java.util.List; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectStatus; -import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ScanResult; -import net.modtale.model.project.ScanStatus; -import net.modtale.repository.user.UserRepository; -import net.modtale.service.project.query.ProjectListingQueryService; -import net.modtale.service.project.query.ProjectService; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class ProjectReviewQueryServiceTest { - - @Test - void verificationQueueReturnsRiskOrderedDedicatedDtos() { - ProjectReviewQueueService queueService = mock(ProjectReviewQueueService.class); - ProjectReviewQueryService queryService = new ProjectReviewQueryService( - mock(UserRepository.class), - mock(ProjectService.class), - queueService, - mock(ProjectListingQueryService.class) - ); - - Project olderClean = queueProject("clean", "2026-01-01T00:00:00", ScanStatus.CLEAN, "ALLOW", 5, 0); - Project newerRisky = queueProject("risky", "2026-02-01T00:00:00", ScanStatus.SUSPICIOUS, "REVIEW", 80, 2); - when(queueService.getVerificationQueue()).thenReturn(List.of(olderClean, newerRisky)); - - List queue = queryService.getVerificationQueue(); - - assertEquals(List.of("risky", "clean"), queue.stream().map(AdminVerificationQueueItemDTO::id).toList()); - assertEquals(80, queue.getFirst().pendingVersion().scan().riskScore()); - assertEquals(2, queue.getFirst().pendingVersion().scan().newIssueCount()); - } - - private static Project queueProject( - String id, - String updatedAt, - ScanStatus scanStatus, - String verdict, - int riskScore, - int newIssueCount - ) { - ScanResult scan = new ScanResult(); - scan.setStatus(scanStatus); - scan.setVerdict(verdict); - scan.setRiskScore(riskScore); - scan.setNewIssueCount(newIssueCount); - - ProjectVersion version = new ProjectVersion(); - version.setId(id + "-version"); - version.setVersionNumber("1.0.0"); - version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); - version.setScanResult(scan); - - Project project = new Project(); - project.setId(id); - project.setTitle(id); - project.setStatus(ProjectStatus.PUBLISHED); - project.setUpdatedAt(updatedAt); - project.setVersions(List.of(version)); - return project; - } -} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java deleted file mode 100644 index 6363b702a..000000000 --- a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.modtale.service.admin.review; - -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectStatus; -import net.modtale.model.project.ProjectVersion; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Query; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ProjectReviewQueueServiceTest { - - private ProjectReviewQueueService projectReviewQueueService; - private MongoTemplate mongoTemplate; - - @BeforeEach - void setUp() { - mongoTemplate = mock(MongoTemplate.class); - projectReviewQueueService = new ProjectReviewQueueService(mongoTemplate); - } - - @Test - void getVerificationQueueUsesOneFilteredProjection() { - Project pendingProject = project("pending-1", "Pending", ProjectStatus.PENDING); - Project pendingReviewProject = project("published-1", "Review Me", ProjectStatus.PUBLISHED); - ProjectVersion reviewVersion = new ProjectVersion(); - reviewVersion.setVersionNumber("3.0.0"); - reviewVersion.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); - pendingReviewProject.setVersions(List.of(reviewVersion)); - - when(mongoTemplate.find(any(Query.class), eq(Project.class))) - .thenReturn(List.of(pendingProject, pendingReviewProject)); - - List queue = projectReviewQueueService.getVerificationQueue(); - - assertEquals(List.of("pending-1", "published-1"), queue.stream().map(Project::getId).toList()); - - ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(Query.class); - verify(mongoTemplate, times(1)).find(queryCaptor.capture(), eq(Project.class)); - Query query = queryCaptor.getValue(); - String criteria = query.getQueryObject().toString(); - assertTrue(criteria.contains("$or")); - assertTrue(criteria.contains("$not")); - assertTrue(criteria.contains("SCANNING")); - assertEquals(1, query.getFieldsObject().get("versions.scanResult.riskScore")); - assertFalse(query.getFieldsObject().containsKey("versions.scanResult.issues")); - assertFalse(query.getFieldsObject().containsKey("comments")); - } - - private static Project project(String id, String title, ProjectStatus status) { - Project project = new Project(); - project.setId(id); - project.setTitle(title); - project.setStatus(status); - return project; - } - -} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java new file mode 100644 index 000000000..7a45f986d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java @@ -0,0 +1,21 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectReviewSnapshotTest { + @Test void coversMetadataAndInternalArtifactVerificationWithoutChurningOnPopularity() { + var project = new Project(); project.setId("project"); project.setTitle("reviewed"); + var version = new ProjectVersion(); version.setId("version"); + version.setScanResult(ScanEvidenceFixtures.complete(true)); project.setVersions(List.of(version)); + String token = ProjectReviewSnapshot.token(project); + project.setDownloadCount(42); project.setFavoriteCount(7); project.setRankingDirty(true); + assertEquals(token, ProjectReviewSnapshot.token(project)); + project.setTitle("changed"); assertNotEquals(token, ProjectReviewSnapshot.token(project)); + project.setTitle("reviewed"); version.getScanResult().setArtifactVerified(false); + assertNotEquals(token, ProjectReviewSnapshot.token(project)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java new file mode 100644 index 000000000..fdfdfa84f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java @@ -0,0 +1,66 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.*; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class RawReviewSnapshotReaderTest { + MongoClient client;MongoTemplate mongo;RawReviewSnapshotReader reader;String database; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");database="warden_raw_capture_"+UUID.randomUUID().toString().replace("-","");mongo=new MongoTemplate(client,database);reader=new RawReviewSnapshotReader(mongo); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + Document version(){return new Document("_id","v").append("scanResult","invalid typed state").append("unknown",new Document("date",new Date(123)).append("number",-0.0));} + void insert(Object id,List versions){mongo.getCollection("projects").insertOne(new Document("_id",id).append("versions",versions));} + byte[] bytes(Document document){var buffer=new RawBsonDocument(document,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + @Test void sameTextBsonRootsRemainDistinctAndRawBytesAreArchived() { + String hex="abcdefabcdefabcdefabcdef";var v=version();insert(hex,List.of(v));insert(new ObjectId(hex),List.of(new Document(v).append("different",true))); + var captured=reader.capture(hex,0,"v");assertArrayEquals(bytes(v),captured.versionBytes());assertTrue(reader.isCurrent(captured)); + assertNotEquals(captured.sha256(),reader.capture(new ObjectId(hex),0,"v").sha256()); + var archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));var saved=archive.retain(captured.forArchive(UUID.randomUUID().toString(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,1000,2000)); + assertArrayEquals(captured.versionBytes(),saved.versionBytes());assertEquals(hex,saved.projectId());assertTrue(reader.isCurrent(captured)); + captured.versionBytes()[0]++;assertArrayEquals(bytes(v),captured.versionBytes()); + } + @ParameterizedTest @ValueSource(strings={"changed","shifted","duplicate","removed"}) + void laterChangesPreventCurrentSnapshotClaim(String mutation) { + insert("p",List.of(version()));var captured=reader.capture("p",0,"v");var collection=mongo.getCollection("projects"); + switch(mutation) { + case "changed"->collection.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0.unknown.number",1.0))); + case "shifted"->collection.updateOne(new Document("_id","p"),new Document("$push",new Document("versions",new Document("$each",List.of(new Document("_id","other"))).append("$position",0)))); + case "duplicate"->collection.updateOne(new Document("_id","p"),new Document("$push",new Document("versions",new Document("_id","v").append("different",true)))); + default->collection.deleteOne(new Document("_id","p")); + } + assertFalse(reader.isCurrent(captured)); + } + @Test void unrelatedMalformedSlotsDoNotNormalizeOrReplaceSelectedRecord() { + insert("p",List.of("bad sibling",new Document("_id",42),version())); + assertArrayEquals(bytes(version()),reader.capture("p",2,"v").versionBytes()); + assertThrows(IllegalStateException.class,()->reader.capture("p",0,"v"));assertThrows(IllegalStateException.class,()->reader.capture("p",1,"v")); + assertThrows(IllegalStateException.class,()->reader.capture("p",3,"v"));assertThrows(IllegalStateException.class,()->reader.capture("p",2,"other")); + } + @Test void duplicateIdentityKeysCannotBeCaptured() { + var output=new org.bson.io.BasicOutputBuffer();try(var writer=new BsonBinaryWriter(output)) { + writer.writeStartDocument();writer.writeString("_id","p");writer.writeStartArray("versions");writer.writeStartDocument(); + writer.writeString("_id","v");writer.writeString("_id","v");writer.writeEndDocument();writer.writeEndArray();writer.writeEndDocument(); + } + mongo.getCollection("projects").withDocumentClass(RawBsonDocument.class).insertOne(new RawBsonDocument(output.toByteArray())); + assertThrows(IllegalStateException.class,()->reader.capture("p",0,"v")); + } + @Test void databaseCollationCannotSelectAnotherRoot() { + mongo.getDb().createCollection("projects",new com.mongodb.client.model.CreateCollectionOptions().collation(com.mongodb.client.model.Collation.builder().locale("en").collationStrength(com.mongodb.client.model.CollationStrength.SECONDARY).build())); + insert("PROJECT",List.of(version()));assertThrows(IllegalStateException.class,()->reader.capture("project",0,"v"));assertTrue(reader.isCurrent(reader.capture("PROJECT",0,"v"))); + } + @Test void invalidPositionsFailBeforeReading() { + assertThrows(IllegalArgumentException.class,()->reader.capture(42,0,"v"));assertThrows(IllegalArgumentException.class,()->reader.capture("p",-1,"v")); + assertThrows(IllegalArgumentException.class,()->reader.capture("p",0,"bad\n"));assertThrows(IllegalArgumentException.class,()->reader.capture("p",0,null)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java new file mode 100644 index 000000000..547534306 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java @@ -0,0 +1,118 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewCancellationAccessTest { + AccountService accounts;ReviewRepairWorkflow workflow;ReviewOrphanCancellationJournal journal; + ReviewOrphanCancellationExecutor executor;ReviewCancellationReconciler reconciler;ReviewCancellationAccess access;User actor; + AtomicLong time=new AtomicLong(); + ReviewOrphanCancellationJournal.Prepared original=new ReviewOrphanCancellationJournal.Prepared(UUID.randomUUID().toString(),"a".repeat(64),1,1001); + ReviewCancellationAccess.Check check=new ReviewCancellationAccess.Check(UUID.randomUUID().toString(),original); + @BeforeEach void setup() { + accounts=mock(AccountService.class);journal=mock(ReviewOrphanCancellationJournal.class);executor=mock(ReviewOrphanCancellationExecutor.class);reconciler=mock(ReviewCancellationReconciler.class); + workflow=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1,1000,time::get); + access=new ReviewCancellationAccess(accounts,workflow,journal,executor,reconciler);actor=ReviewRepairAccessTest.user("actor"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(actor,null,List.of())); + when(accounts.getCurrentUser(any())).thenReturn(actor); + } + @AfterEach void cleanup(){workflow.close();SecurityContextHolder.clearContext();} + @Test void allOperationsRequireBothPermissionsBeforeValidationOrWork() { + for(var permissions:List.of(Set.of(AdminPermission.PROJECT_REVIEW_READ),Set.of(AdminPermission.PROJECT_VERSION_RESCAN),Set.of(AdminPermission.PROJECT_REVIEW_DECIDE))) { + actor.setAdminPermissions(permissions);assertThrows(SecurityException.class,()->access.history(null));assertThrows(SecurityException.class,()->access.capability());assertThrows(SecurityException.class,()->access.preview(null)); + assertThrows(SecurityException.class,()->access.recover(null));assertThrows(SecurityException.class,()->access.prepare(null));assertThrows(SecurityException.class,()->access.execute(null)); + assertThrows(SecurityException.class,()->access.receipt(null));assertThrows(SecurityException.class,()->access.check(null));assertThrows(SecurityException.class,()->access.checkReceipt(null)); + } + verifyNoInteractions(journal,executor,reconciler); + } + @Test void deletedAndMissingAccountsHaveNoAuthority() { + actor.setDeletedAt(java.time.LocalDateTime.now());assertThrows(SecurityException.class,()->access.execute(original)); + when(accounts.getCurrentUser(any())).thenReturn(null);assertThrows(SecurityException.class,()->access.check(check));verifyNoInteractions(journal,executor,reconciler); + } + @Test void malformedRequestsNeverReachComponents() { + assertThrows(IllegalArgumentException.class,()->access.preview("wrong"));assertThrows(IllegalArgumentException.class,()->access.recover("wrong"));assertThrows(IllegalArgumentException.class,()->access.prepare("wrong")); + assertThrows(IllegalArgumentException.class,()->access.execute(new ReviewOrphanCancellationJournal.Prepared(original.isolationId(),original.targetSha256(),1,120002))); + assertThrows(IllegalArgumentException.class,()->access.receipt(new ReviewOrphanCancellationJournal.Prepared(original.isolationId(),"A".repeat(64),1,2))); + assertThrows(IllegalArgumentException.class,()->access.check(new ReviewCancellationAccess.Check("bad",original))); + assertThrows(IllegalArgumentException.class,()->access.checkReceipt(new ReviewCancellationAccess.Check(check.id(),null))); + verifyNoInteractions(journal,executor,reconciler); + } + @Test void previewRechecksAccountAndNeverPreparesAnOperation() { + when(journal.preview(eq(original.isolationId()),eq("actor"),any())).thenAnswer(i->{ + assertTrue(i.getArgument(2).getAsBoolean());actor.setAdminPermissions(Set.of());return null; + }); + assertThrows(SecurityException.class,()->access.preview(original.isolationId())); + verify(journal).preview(eq(original.isolationId()),eq("actor"),any());verifyNoMoreInteractions(journal);verifyNoInteractions(executor,reconciler); + } + @Test void capabilityUsesAdmissionWithoutRemoteDiscovery() { + assertEquals("CANCEL_ORIGINAL_REVIEW",access.capability().action());verifyNoInteractions(journal,executor,reconciler); + workflow.close();assertThrows(IllegalStateException.class,()->access.capability()); + } + @Test void preparationUsesServerActorAndDoesNotDispatch() { + when(journal.prepare(eq(original.isolationId()),eq("actor"),any())).thenAnswer(i->{assertTrue(i.getArgument(2).getAsBoolean());return original;}); + assertEquals(original,access.prepare(original.isolationId()));verifyNoInteractions(executor,reconciler); + } + @Test void executeRechecksAccountIdentityAndRefusesResponseAfterSwitch() { + when(executor.execute(eq(original),eq("actor"),any())).thenAnswer(i->{ + var allowed=i.getArgument(2);assertTrue(allowed.getAsBoolean()); + when(accounts.getCurrentUser(any())).thenReturn(ReviewRepairAccessTest.user("other"));assertFalse(allowed.getAsBoolean()); + return new ReviewOrphanCancellationExecutor.Execution("UNKNOWN",null); + }); + assertThrows(SecurityException.class,()->access.execute(original));assertEquals(0,workflow.status().active()); + } + @Test void sharedDeadlineIsVisibleInsideRemoteWorkAndAdmissionRemainsUntilReturn() { + when(reconciler.check(eq(check.id()),eq(original),eq("actor"),any())).thenAnswer(i->{ + assertEquals(1,workflow.status().active());time.set(TimeUnit.SECONDS.toNanos(1)); + assertThrows(IllegalStateException.class,()->i.getArgument(3).getAsBoolean()); + assertEquals(1,workflow.status().overdue());return null; + }); + assertThrows(IllegalStateException.class,()->access.check(check));assertEquals(0,workflow.status().active()); + } + @Test void nestedWorkCannotBypassSharedAdmission() { + when(journal.prepare(anyString(),anyString(),any())).thenAnswer(i->{assertThrows(IllegalStateException.class,()->access.check(check));return original;}); + assertEquals(original,access.prepare(original.isolationId()));verifyNoInteractions(reconciler); + } + @Test void closureIsObservedInsideDispatchAndPreventsNewCalls() { + when(executor.execute(any(),anyString(),any())).thenAnswer(i->{workflow.close();assertThrows(IllegalStateException.class,()->i.getArgument(2).getAsBoolean());return null;}); + assertThrows(IllegalStateException.class,()->access.execute(original));assertEquals("CLOSED",workflow.status().state()); + assertThrows(IllegalStateException.class,()->access.prepare(original.isolationId()));verifyNoInteractions(journal); + } + @Test void recoveryUsesOnlyKnownIsolationIdentityAndNeverPreparesOrDispatches() { + var expected=new ReviewOrphanCancellationJournal.Receipt(original,"PREPARED",null,null); + when(journal.recover(eq(original.isolationId()),eq("actor"),any())).thenAnswer(i->{assertTrue(i.getArgument(2).getAsBoolean());return expected;}); + assertEquals(expected,access.recover(original.isolationId())); + verify(journal).recover(eq(original.isolationId()),eq("actor"),any());verifyNoMoreInteractions(journal);verifyNoInteractions(executor,reconciler); + } + @Test void localReceiptLookupsNeverInvokeRemoteOperationsOrRenewPreparation() { + var receipt=new ReviewOrphanCancellationJournal.Receipt(original,"UNKNOWN",null,2L); + var later=new ReviewCancellationReconciler.Receipt(check.id(),original,"READING",null,null); + when(journal.receipt(eq(original),eq("actor"),any())).thenReturn(receipt); + when(reconciler.receipt(eq(check.id()),eq(original),eq("actor"),any())).thenReturn(later); + assertEquals(receipt,access.receipt(original));assertEquals(later,access.checkReceipt(check)); + verify(journal).receipt(eq(original),eq("actor"),any());verify(reconciler).receipt(eq(check.id()),eq(original),eq("actor"),any()); + verifyNoMoreInteractions(journal,reconciler);verifyNoInteractions(executor); + } + @Test void historyUsesSharedAdmissionAndRefusesResultsAfterAccountRevocation() { + var request=new ReviewCancellationAccess.History(original,null,10); + when(reconciler.history(eq(original),eq("actor"),isNull(),eq(10),any())).thenAnswer(i->{ + assertEquals(1,workflow.status().active());assertTrue(i.getArgument(4).getAsBoolean()); + actor.setAdminPermissions(Set.of());return new ReviewObservationReader.Page(List.of(),null,"OBSERVATION_ID"); + }); + assertThrows(SecurityException.class,()->access.history(request));assertEquals(0,workflow.status().active());verifyNoInteractions(journal,executor); + } + @Test void malformedHistoryNeverReachesTheReader() { + for(var request:List.of(new ReviewCancellationAccess.History(original,null,0),new ReviewCancellationAccess.History(original,null,26), + new ReviewCancellationAccess.History(original,"c1."+check.id()+"."+check.id(),10),new ReviewCancellationAccess.History(null,null,10))) + assertThrows(IllegalArgumentException.class,()->access.history(request)); + verifyNoInteractions(journal,executor,reconciler); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java new file mode 100644 index 000000000..829a4a35d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java @@ -0,0 +1,36 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import net.modtale.service.security.scan.RemoteReviewClient; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewCancellationConfigurationTest { + ApplicationContextRunner runner(){return new ApplicationContextRunner().withUserConfiguration(ReviewCancellationConfiguration.class);} + @Test void disabledConfigurationCreatesNoComponentsEvenWithOtherFlagsEnabled() { + runner().withPropertyValues("app.warden.repair.enabled=true","app.warden.jobs.enabled=true").run(c->{assertNull(c.getStartupFailure());assertEquals(0,c.getBeansOfType(ReviewCancellationAccess.class).size());}); + } + @Test void enablingWithoutRequiredRepairAndRemoteDependenciesFailsStartup() { + runner().withPropertyValues("app.warden.repair.cancellation.enabled=true").run(c->assertNotNull(c.getStartupFailure())); + } + @Test void enabledComponentsShareWorkflowAndCloseOnContextDestruction() { + var mongo=mock(MongoTemplate.class);@SuppressWarnings("unchecked") MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF); + when(mongo.getCollection(anyString())).thenReturn(collection); + var workflow=mock(ReviewRepairWorkflow.class);var executor=new ReviewOrphanCancellationExecutor[1];var reconciler=new ReviewCancellationReconciler[1]; + runner().withPropertyValues("app.warden.repair.cancellation.enabled=true","app.warden.jobs.enabled=true") + .withBean(MongoTemplate.class,()->mongo).withBean(ReviewSnapshotArchive.class,()->mock(ReviewSnapshotArchive.class)) + .withBean(ReviewIsolationExecutor.class,()->mock(ReviewIsolationExecutor.class)).withBean(ReviewRepairWorkflow.class,()->workflow) + .withBean(AccountService.class,()->mock(AccountService.class)).withBean(RemoteReviewClient.class,()->mock(RemoteReviewClient.class)) + .withBean(AppReviewRepairProperties.class,()->new AppReviewRepairProperties(false,"",Map.of(),1,300000)) + .run(c->{assertNull(c.getStartupFailure());assertNotNull(c.getBean(ReviewCancellationAccess.class));executor[0]=c.getBean(ReviewOrphanCancellationExecutor.class);reconciler[0]=c.getBean(ReviewCancellationReconciler.class);verifyNoInteractions(workflow);}); + assertEquals("SHUTDOWN",executor[0].execute(null,"actor",()->true).state()); + assertThrows(IllegalStateException.class,()->reconciler[0].check("11111111-1111-1111-1111-111111111111",null,"actor",()->true)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java new file mode 100644 index 000000000..182884665 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java @@ -0,0 +1,131 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewIsolationExecutorTest { + MongoClient client;MongoTemplate mongo;MongoCollection projects;RawReviewSnapshotReader reader;ReviewSnapshotArchive archive;ReviewRepairJournal journal; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031");if(!Set.of("27031","27032").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=10000"); + mongo=new MongoTemplate(client,"warden_isolate_"+UUID.randomUUID().toString().replace("-",""));projects=mongo.getCollection("projects"); + var scan=new Document("status","SCANNING").append("scanState","REMOTE_REVIEW").append("verdict","BLOCK").append("riskScore",73) + .append("issues",List.of(new Document("severity","HIGH").append("description","Retained finding").append("resolved",false))) + .append("scanRequestId",UUID.randomUUID().toString()).append("scanAttempt",1).append("manualRescan",true).append("artifactVerified",true); + var version=new Document("_id","v").append("reviewStatus","PENDING").append("scanResult",scan).append("securityApprovedAt",123L) + .append("scheduledPublishDate",123L).append("approvedSecurityContextSha256","a".repeat(64)).append("unknownHistory",List.of("retain")); + projects.insertOne(new Document("_id","p").append("versions",List.of(version))); + reader=new RawReviewSnapshotReader(mongo);archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));journal=new ReviewRepairJournal(mongo,archive); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewRepairPreparation.Prepared prepare() { + var req=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + return new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000).prepare(req,()->true); + } + ReviewIsolationExecutor executor(){return new ReviewIsolationExecutor(mongo,archive,reader,journal);} + Document version(){return projects.find().first().getList("versions",Document.class).getFirst();} + Document operation(){return mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first();} + void set(String path,Object value){projects.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0."+path,value)));} + @Test void isolatesWithAtomicReceiptPreservedEvidenceAndNoSecurityClearance() { + var before=reader.capture("p",0,"v");var prepared=prepare();var result=executor().execute(prepared,"actor",()->true); + assertEquals("APPLIED",result.state());var v=version();var scan=v.get("scanResult",Document.class); + assertEquals("PENDING",v.get("reviewStatus"));assertEquals("FAILED",scan.get("status"));assertEquals("REMOTE_ISOLATED",scan.get("scanState")); + assertEquals("BLOCK",scan.get("verdict"));assertEquals(73,scan.get("riskScore"));assertEquals(false,scan.get("artifactVerified")); + assertEquals(new org.bson.RawBsonDocument(before.versionBytes()).decode(new org.bson.codecs.DocumentCodec()).get("scanResult",Document.class).get("issues"),scan.get("issues")); + assertEquals(List.of("retain"),v.get("unknownHistory"));assertNull(v.get("scheduledPublishDate"));assertEquals(0L,v.get("securityApprovedAt"));assertNull(v.get("approvedSecurityContextSha256")); + assertEquals(prepared.id(),v.get("reviewIsolation",Document.class).get("operationId"));assertInstanceOf(Date.class,v.get("reviewIsolation",Document.class).get("isolatedAt")); + assertArrayEquals(before.versionBytes(),archive.load(prepared.id()).versionBytes());assertEquals(reader.capture("p",0,"v").sha256(),result.afterSha256());assertEquals("APPLIED",operation().get("state")); + assertEquals(result,executor().execute(prepared,"actor",()->true));assertEquals(v,version()); + } + @Test void healthyQueuedReviewIsIneligibleWithoutClaim() { + set("scanResult.scanState","QUEUED");var before=version();assertEquals("INELIGIBLE",executor().execute(prepare(),"actor",()->true).state());assertNull(operation());assertEquals(before,version()); + } + @Test void unreadableRemainingStateCannotDisappearFromDiagnostics() { + set("scanResult.manualRescan",null);var before=version();assertEquals("INELIGIBLE",executor().execute(prepare(),"actor",()->true).state());assertNull(operation());assertEquals(before,version()); + } + @Test void activeLeasePreventsMutationEvenWhenPollShapeIsBroken() { + set("scanResult.remotePoll",new Document("leaseUntil",new Date(System.currentTimeMillis()+120000)));var before=version(); + assertEquals("NOT_APPLIED",executor().execute(prepare(),"actor",()->true).state());assertEquals(before,version());assertEquals("NOT_APPLIED",operation().get("state")); + } + @Test void changedBytesSincePreparationDoNotApply() { + var prepared=prepare();set("scanResult.scanAttempt",1L);var before=version();assertEquals("NOT_APPLIED",executor().execute(prepared,"actor",()->true).state());assertEquals(before,version()); + } + @Test void concurrentEditAfterSnapshotAbortsBothWrites() { + var prepared=prepare();var intercepted=spy(reader);var once=new AtomicBoolean(); + doAnswer(i->{var result=i.callRealMethod();if(once.compareAndSet(false,true))set("scanResult.scanAttempt",1L);return result;}).when(intercepted).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + var result=new ReviewIsolationExecutor(mongo,archive,intercepted,journal).execute(prepared,"actor",()->true); + assertEquals("UNKNOWN",result.state());assertEquals("SCANNING",version().get("scanResult",Document.class).get("status"));assertEquals(1L,version().get("scanResult",Document.class).get("scanAttempt"));assertEquals("UNKNOWN",operation().get("state")); + } + @Test void permissionRevokedAfterVersionWriteRollsBackEverything() { + var prepared=prepare();var before=version();var allowed=new AtomicBoolean(true);var intercepted=spy(reader); + doAnswer(i->{var result=(RawReviewSnapshotReader.Captured)i.callRealMethod();if(!result.sha256().equals(prepared.sha256()))allowed.set(false);return result;}).when(intercepted).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + assertThrows(SecurityException.class,()->new ReviewIsolationExecutor(mongo,archive,intercepted,journal).execute(prepared,"actor",allowed::get)); + assertEquals(before,version());assertEquals("UNKNOWN",operation().get("state")); + } + @Test void lostCommitAcknowledgementReconcilesWithoutRepeatingMutation() { + var prepared=prepare();var wrapped=spy(mongo);var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(wrapped).getMongoDatabaseFactory(); + doAnswer(i->{var session=spy((ClientSession)i.callRealMethod());doAnswer(c->{c.callRealMethod();var lost=new MongoException("lost commit acknowledgement");lost.addLabel("UnknownTransactionCommitResult");throw lost;}).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ReviewIsolationExecutor(wrapped,archive,reader,journal).execute(prepared,"actor",()->true);assertEquals("APPLIED",result.state());assertEquals(reader.capture("p",0,"v").sha256(),result.afterSha256());assertEquals(result,executor().execute(prepared,"actor",()->true)); + } + @Test void malformedBindingIsArchivedBeforeDetachment() { + set("scanResult.remoteReview",new Document("jobId","unreadable"));var prepared=prepare();var original=archive.load(prepared.id()).versionBytes(); + assertEquals("APPLIED",executor().execute(prepared,"actor",()->true).state());assertNull(version().get("scanResult",Document.class).get("remoteReview"));assertArrayEquals(original,archive.load(prepared.id()).versionBytes()); + } + @Test void validMismatchedBindingRemainsAvailableForReconciliation() { + var binding=new Document("projectId","other").append("versionId","v").append("requestId",UUID.randomUUID().toString()).append("attempt",1) + .append("filePath","mods/example.jar").append("artifactSha256","a".repeat(64)).append("contextSha256","b".repeat(64)) + .append("policyVersion","warden-3.0.0:"+"c".repeat(64)).append("reviewConfigSha256","d".repeat(64)).append("jobId",UUID.randomUUID().toString()).append("manualRescan",true); + set("scanResult.remoteReview",binding);assertEquals("APPLIED",executor().execute(prepare(),"actor",()->true).state());assertEquals(binding,version().get("scanResult",Document.class).get("remoteReview")); + } + @Test void expiredIntentCannotChangeVersion() { + var capture=reader.capture("p",0,"v");var now=System.currentTimeMillis();var id=UUID.randomUUID().toString(); + archive.retain(capture.forArchive(id,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,now-20000,now-10000)); + var prepared=new ReviewRepairPreparation.Prepared(id,capture.sha256(),now-20000,now-10000);var before=version(); + assertEquals("UNKNOWN",executor().execute(prepared,"actor",()->true).state());assertEquals(before,version());assertEquals("RESERVED",operation().get("state")); + } + @Test void duplicateVersionIntroducedAfterPreparationCannotBeIsolated() { + var prepared=prepare();projects.updateOne(new Document("_id","p"),new Document("$push",new Document("versions",version())));var before=projects.find().first(); + assertEquals("UNKNOWN",executor().execute(prepared,"actor",()->true).state());assertEquals(before,projects.find().first());assertEquals("UNKNOWN",operation().get("state")); + } + @Test void standaloneTopologyIsRejectedBeforeClaim() { + var prepared=prepare();var wrapped=spy(mongo);var db=spy(mongo.getDb());doReturn(db).when(wrapped).getDb(); + doReturn(new Document("isWritablePrimary",true)).when(db).runCommand(eq(new Document("hello",1)),eq(ReadPreference.primary())); + var before=version();assertThrows(IllegalStateException.class,()->new ReviewIsolationExecutor(wrapped,archive,reader,journal).execute(prepared,"actor",()->true));assertEquals(before,version());assertNull(operation()); + } + @Test void wrongActorCannotAccessOrClaimRepair() { + var prepared=prepare();assertThrows(SecurityException.class,()->executor().execute(prepared,"other",()->true));assertNull(operation()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"shutdown","deadline"}) + void workflowCancellationAfterVersionWriteAbortsRepair(String mode) { + var prepared=prepare();var before=version();var intercepted=spy(reader);var ticks=new java.util.concurrent.atomic.AtomicLong();var reachedWrite=new AtomicBoolean(); + var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000); + var workflow=new ReviewRepairWorkflow(preparation,new ReviewIsolationExecutor(mongo,archive,intercepted,journal),1,1000,ticks::get); + doAnswer(i->{var result=(RawReviewSnapshotReader.Captured)i.callRealMethod();if(!result.sha256().equals(prepared.sha256())) { + reachedWrite.set(true); + if(mode.equals("shutdown"))workflow.close();else ticks.addAndGet(1000000000L); + }return result;}).when(intercepted).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + assertEquals("UNKNOWN",workflow.isolate(prepared,"actor",()->true).state());assertEquals(before,version());assertEquals("UNKNOWN",operation().get("state")); + assertTrue(reachedWrite.get()); + assertEquals(new ReviewRepairWorkflow.Status(mode.equals("shutdown")?"CLOSED":"OPEN",0),workflow.status()); + } + + @Test void completeWorkflowUsesDriverTimeoutsAndCommitsRepair() { + var prepared=prepare();var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000); + try(var workflow=new ReviewRepairWorkflow(preparation,executor(),1)) { + assertEquals("APPLIED",workflow.isolate(prepared,"actor",()->true).state());assertEquals("APPLIED",operation().get("state")); + } + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java new file mode 100644 index 000000000..19ea2d33e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java @@ -0,0 +1,95 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.*; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static net.modtale.service.admin.review.ReviewOriginInventory.OriginState.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewOriginInventoryTest { + MongoClient client;MongoDatabase db;MongoCollection projects;ReviewOriginInventory inventory; + String request=UUID.randomUUID().toString(),job=UUID.randomUUID().toString(); + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_origin_inventory_"+UUID.randomUUID().toString().replace("-","")); + projects=db.getCollection("projects");inventory=new ReviewOriginInventory(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document binding(){return new Document("requestId",request).append("jobId",job);} + Document origin(){return new Document("deploymentId","11111111-1111-1111-1111-111111111111").append("callerScope","a".repeat(64));} + Document version(Object id,Object binding){return new Document("_id",id).append("scanResult",new Document("remoteReview",binding));} + void insert(Object id,List versions){projects.insertOne(new Document("_id",id).append("versions",versions));} + @ParameterizedTest @ValueSource(strings={"SCANNING","FAILED","COMPLETED","CANCELLED","malformed"}) + void retainedBindingsRemainVisibleIndependentOfCurrentReviewOrScanState(String status) { + var v=version("v",binding());v.put("reviewStatus","APPROVED");v.get("scanResult",Document.class).put("status",status);insert("a",List.of(v)); + var item=inventory.page(null,64).items().getFirst();assertEquals(MISSING,item.originState());assertEquals(request,item.requestId());assertEquals(job,item.jobId());assertFalse(item.ambiguousVersion()); + } + @Test void recordedMissingAndInvalidOriginsStayDistinctWithoutClaimingAuthority() { + insert("a",List.of(version("missing",binding()),version("null",binding().append("origin",null)),version("recorded",binding().append("origin",origin())), + version("bad",binding().append("origin",new Document("deploymentId","not-a-uuid"))),version("array",List.of(binding())))); + var page=inventory.page(null,64);assertEquals(List.of(MISSING,MISSING,RECORDED,INVALID,UNREADABLE_BINDING),page.items().stream().map(ReviewOriginInventory.Item::originState).toList()); + assertNull(page.items().getLast().requestId());assertNull(page.items().getLast().jobId()); + } + @Test void oversizedMalformedPayloadsAreNotEchoedOrDeserialized() { + String payload="sensitive-payload".repeat(50000); + insert("a",List.of(version("v",binding().append("origin",new Document("deploymentId",payload).append("callerScope",List.of(payload))).append("requestId",payload).append("jobId",List.of(payload))).append("unrelated",payload))); + var page=inventory.page(null,1);assertEquals(INVALID,page.items().getFirst().originState());assertNull(page.items().getFirst().requestId());assertNull(page.items().getFirst().jobId());assertFalse(page.toString().contains("sensitive-payload")); + } + @Test void duplicateInvalidAndPhysicalPositionsArePreserved() { + insert("a",List.of(version("same",binding()),version("same",binding()),version(List.of("bad"),binding()),version("bad\n",binding()))); + var page=inventory.page(null,64);assertEquals(List.of(0,1,2,3),page.items().stream().map(ReviewOriginInventory.Item::versionIndex).toList()); + assertTrue(page.items().stream().allMatch(ReviewOriginInventory.Item::ambiguousVersion));assertNull(page.items().get(2).versionId());assertNull(page.items().get(3).versionId()); + } + @Test void emptySlicesContinueWithoutSkippingLaterBindings() { + insert("a",List.of(new Document("_id","one"),version("two",null),version("three",binding()))); + var first=inventory.page(null,1);assertTrue(first.items().isEmpty());assertEquals(1,first.examined()); + var second=inventory.page(first.next(),1);assertTrue(second.items().isEmpty());var third=inventory.page(second.next(),1);assertEquals(2,third.items().getFirst().versionIndex()); + assertNull(inventory.page(third.next(),1).next()); + } + @Test void mixedBsonProjectIdentitiesAndDeletedCursorRootContinueInOrder() { + var oid=new ObjectId("abcdefabcdefabcdefabcdef");insert("a",List.of(version("a",binding())));insert(oid,List.of(version("o",binding()))); + var first=inventory.page(null,1);assertEquals("a",first.items().getFirst().projectId());projects.deleteOne(new Document("_id","a")); + var second=inventory.page(first.next(),1);assertEquals(oid,second.items().getFirst().projectId());assertNull(inventory.page(second.next(),1).next()); + } + @Test void binaryOrderingDoesNotConflateProjectNames() { + insert("A",List.of(version("v",binding())));insert("a",List.of(version("v",binding()))); + var first=inventory.page(null,1);assertEquals("A",first.items().getFirst().projectId());assertEquals("a",inventory.page(first.next(),1).items().getFirst().projectId()); + } + @Test void malformedSiblingsAndRootsDoNotForceTypedProjectReads() { + projects.insertOne(new Document("_id",32).append("versions",List.of(version("v",binding())))); + projects.insertOne(new Document("_id","bad").append("versions","malformed")); + insert("a",List.of(42,new Document("scanResult",List.of(binding())),version("v",binding()))); + var page=inventory.page(null,64);assertEquals(3,page.examined());assertEquals(2,page.items().size());assertEquals(UNREADABLE_BINDING,page.items().getFirst().originState());assertTrue(page.items().getFirst().ambiguousVersion());assertEquals(2,page.items().getLast().versionIndex());assertEquals(MISSING,page.items().getLast().originState());assertNull(inventory.page(page.next(),64).next()); + } + @Test void inventoryPreservesExactStoredBytesAndDoesNotBackfillOrigin() { + insert("a",List.of(version("v",binding()).append("unmodeled",new Document("int",1).append("long",1L)))); + var raw=projects.withDocumentClass(RawBsonDocument.class);var before=raw.find().first();byte[] bytes=new byte[before.getByteBuffer().remaining()];before.getByteBuffer().get(bytes); + inventory.page(null,64);var after=raw.find().first();byte[] actual=new byte[after.getByteBuffer().remaining()];after.getByteBuffer().get(actual);assertArrayEquals(bytes,actual); + } + @Test void limitsRejectBeforeDatabaseWorkAndResponsesAreImmutable() { + assertThrows(IllegalArgumentException.class,()->inventory.page(null,0));assertThrows(IllegalArgumentException.class,()->inventory.page(null,65)); + insert("a",List.of(version("v",binding())));var page=inventory.page(null,1);assertThrows(UnsupportedOperationException.class,()->page.items().clear()); + } + @Test void realInventoryToHttpPreservesEmptyContinuationAndFailedVersionReference() throws Exception { + insert("a",List.of(new Document("_id","empty"),version("v",binding()).append("reviewStatus","APPROVED"))); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(new net.modtale.controller.admin.ReviewOriginController(inventory)).build(); + var first=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/origins/page").param("limit","1")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isEmpty()).andReturn(); + String cursor=new com.fasterxml.jackson.databind.ObjectMapper().readTree(first.getResponse().getContentAsString()).path("nextCursor").asText(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/origins/page").param("limit","1").param("cursor",cursor)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].jobId").value(job)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].position.versionIndex").value(1)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].originState").value("MISSING")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java new file mode 100644 index 000000000..b342b55d5 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java @@ -0,0 +1,126 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.time.Clock; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairAccessIntegrationTest { + ReviewIsolationExecutorTest fixture;AccountService accounts;ReviewRepairWorkflow workflow;ReviewRepairAccess access; + ReviewRepairAccess.Position position=new ReviewRepairAccess.Position("STRING","p",0); + @BeforeEach void setup() { + fixture=new ReviewIsolationExecutorTest();fixture.setup();accounts=mock(AccountService.class); + var actor=ReviewRepairAccessTest.user("actor");SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(actor,null,List.of()));when(accounts.getCurrentUser(any())).thenReturn(actor); + configure(fixture.archive); + } + void configure(ReviewSnapshotArchive archive) { + var executor=new ReviewIsolationExecutor(fixture.mongo,archive,fixture.reader,fixture.journal); + workflow=new ReviewRepairWorkflow(new ReviewRepairPreparation(archive,fixture.reader,Clock.systemUTC(),1,60000),executor,1); + access=new ReviewRepairAccess(accounts,workflow,fixture.reader,executor); + } + @AfterEach void cleanup(){workflow.close();fixture.cleanup();SecurityContextHolder.clearContext();} + ReviewRepairPreparation.Prepared prepare() { + var preview=access.inspect(new ReviewRepairAccess.Inspect(position,"v"));assertTrue(preview.eligible());assertEquals("ISOLATE_LOCAL_REVIEW",preview.effect()); + return access.prepare(new ReviewRepairAccess.Prepare(UUID.randomUUID().toString(),position,"v",preview.sha256())); + } + @Test void exactPreviewPreparationExecutionAndReceiptAreComposed() { + var before=fixture.version();var prepared=prepare();assertEquals(before,fixture.version());assertNull(fixture.operation()); + var result=access.execute(prepared);assertEquals("APPLIED",result.state());var receipt=access.receipt(prepared);assertEquals(result.state(),receipt.state());assertEquals(result.afterSha256(),receipt.afterSha256());assertEquals(position,receipt.position());assertEquals("v",receipt.versionId());assertEquals(prepared.sha256(),receipt.beforeSha256()); + assertEquals("BLOCK",fixture.version().get("scanResult",org.bson.Document.class).get("verdict")); + } + @Test void anotherModeratorCannotUseAnExistingPreparedActionOrReceipt() { + var prepared=prepare();when(accounts.getCurrentUser(any())).thenReturn(ReviewRepairAccessTest.user("other")); + assertThrows(SecurityException.class,()->access.execute(prepared));assertThrows(SecurityException.class,()->access.receipt(prepared));assertNull(fixture.operation()); + } + @Test void permissionLossDuringArchivalReturnsNoPreparedAuthority() { + var archive=spy(fixture.archive);doAnswer(i->{var result=i.callRealMethod();when(accounts.getCurrentUser(any())).thenReturn(null);return result;}).when(archive).retain(any());configure(archive); + var before=fixture.version();assertThrows(SecurityException.class,this::prepare);assertEquals(before,fixture.version());assertNull(fixture.operation()); + } + @Test void stalePreviewAndTamperedPreparedIntentCannotMutateVersion() { + var preview=access.inspect(new ReviewRepairAccess.Inspect(position,"v"));fixture.set("changed",true); + assertThrows(IllegalStateException.class,()->access.prepare(new ReviewRepairAccess.Prepare(UUID.randomUUID().toString(),position,"v",preview.sha256()))); + var prepared=prepare();assertThrows(IllegalStateException.class,()->access.execute(new ReviewRepairPreparation.Prepared(prepared.id(),prepared.sha256(),prepared.createdAt(),prepared.expiresAt()+1)));assertNull(fixture.operation()); + } + @Test void storedAccountRevocationIsObservedDuringPreparation() { + var actor=ReviewRepairAccessTest.user("actor");fixture.mongo.save(actor); + var repository=mock(net.modtale.repository.user.UserRepository.class); + when(repository.findById("actor")).thenAnswer(i->Optional.ofNullable(fixture.mongo.findById("actor",net.modtale.model.user.User.class))); + var resolver=new net.modtale.service.user.account.CurrentUserResolutionService(repository); + when(accounts.getCurrentUser(any())).thenAnswer(i->resolver.resolveCurrentUser(i.getArgument(0))); + var archived=spy(fixture.archive); + doAnswer(i->{var result=i.callRealMethod();assertEquals(1,fixture.mongo.updateFirst( + org.springframework.data.mongodb.core.query.Query.query(org.springframework.data.mongodb.core.query.Criteria.where("_id").is("actor")), + new org.springframework.data.mongodb.core.query.Update().set("adminPermissions",List.of()),net.modtale.model.user.User.class).getModifiedCount());return result;}).when(archived).retain(any()); + configure(archived);assertThrows(SecurityException.class,this::prepare);assertNull(fixture.operation()); + } + + @Test void actualHttpPreviewPrepareExecuteAndReceiptReachTransactionalStorage() throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(new net.modtale.controller.admin.ReviewRepairController(access)).build(); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + var previewResponse=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/inspect") + .contentType("application/json").content(mapper.writeValueAsString(new ReviewRepairAccess.Inspect(position,"v")))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")).andReturn(); + var preview=mapper.readValue(previewResponse.getResponse().getContentAsString(),ReviewRepairAccess.Preview.class);assertTrue(preview.eligible()); + var preparedResponse=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/prepare") + .contentType("application/json").content(mapper.writeValueAsString(new ReviewRepairAccess.Prepare(UUID.randomUUID().toString(),position,"v",preview.sha256())))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()).andReturn(); + String prepared=preparedResponse.getResponse().getContentAsString();assertNull(fixture.operation()); + for(String route:List.of("execute","receipt"))mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/"+route) + .contentType("application/json").content(prepared)).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.state").value("APPLIED")); + assertEquals("actor",fixture.operation().get("actor")); + } + + @Test void typedProjectSavePreservesIsolationAndHistoricalReceipt() { + var prepared=prepare();access.execute(prepared);var originalMarker=fixture.version().get("reviewIsolation",org.bson.Document.class); + var project=fixture.mongo.findById("p",net.modtale.model.project.Project.class); + assertNotNull(project.getVersions().getFirst().getReviewIsolation());project.getVersions().getFirst().setVersionNumber("edited");fixture.mongo.save(project); + assertEquals(originalMarker,fixture.version().get("reviewIsolation",org.bson.Document.class)); + var receipt=access.receipt(prepared);assertEquals("APPLIED",receipt.state());assertEquals(position,receipt.position());assertEquals(prepared.sha256(),receipt.beforeSha256()); + assertNotEquals(receipt.afterSha256(),fixture.reader.capture("p",0,"v").sha256()); + fixture.projects.deleteOne(new org.bson.Document("_id","p"));assertEquals(receipt,access.receipt(prepared)); + } + @Test void objectIdReceiptsRetainOriginalBsonIdentity() { + var version=fixture.version();String id="abcdefabcdefabcdefabcdef";fixture.projects.deleteMany(new org.bson.Document()); + fixture.projects.insertOne(new org.bson.Document("_id",new org.bson.types.ObjectId(id)).append("versions",List.of(version))); + position=new ReviewRepairAccess.Position("OBJECT_ID",id,0);var prepared=prepare();access.execute(prepared); + var receipt=access.receipt(prepared);assertEquals(position,receipt.position());assertEquals("v",receipt.versionId());assertEquals(prepared.sha256(),receipt.beforeSha256()); + } + + @Test void recoverByIdRestoresAuthenticatedIntentWithoutExecutingIt() { + var prepared=prepare();var before=fixture.version();var recovered=access.recover(prepared.id()); + assertEquals(prepared,recovered.prepared());assertEquals(position,recovered.receipt().position());assertEquals("UNKNOWN",recovered.receipt().state());assertEquals(before,fixture.version());assertNull(fixture.operation()); + access.execute(prepared);var applied=access.recover(prepared.id());assertEquals("APPLIED",applied.receipt().state());assertEquals(prepared,applied.prepared()); + when(accounts.getCurrentUser(any())).thenReturn(ReviewRepairAccessTest.user("other"));assertThrows(SecurityException.class,()->access.recover(prepared.id())); + } + @Test void operationDiscoveryAndRecoveryNeverReclaimAnUnknownClaim() { + var prepared=prepare();var claim=fixture.journal.claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true);fixture.journal.markUnknown(claim,()->true); + new ReviewRepairOperationReader(fixture.mongo).initialize();var before=fixture.version();var operation=fixture.operation(); + var page=access.operations(null,25);assertEquals(List.of(new ReviewRepairOperationReader.Item(prepared.id(),"UNKNOWN")),page.items()); + assertEquals("UNKNOWN",access.recover(prepared.id()).receipt().state());assertEquals(before,fixture.version());assertEquals(operation,fixture.operation()); + } + + @Test void explicitClosureHttpActionPreservesOriginalIdentityAndDoesNotChangeVersion()throws Exception { + var capture=fixture.reader.capture("p",0,"v");long now=System.currentTimeMillis();String id=UUID.randomUUID().toString(); + fixture.archive.retain(capture.forArchive(id,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,now-20000,now-10000)); + var prepared=new ReviewRepairPreparation.Prepared(id,capture.sha256(),now-20000,now-10000); + assertNull(fixture.journal.claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true));var before=fixture.version(); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(new net.modtale.controller.admin.ReviewRepairController(access)).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/close-expired") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(prepared))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.state").value("NOT_APPLIED")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.beforeSha256").value(prepared.sha256())); + assertEquals(before,fixture.version());assertEquals(position,access.receipt(prepared).position());assertEquals("NOT_APPLIED",access.recover(id).receipt().state()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java new file mode 100644 index 000000000..c9ffdb17c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java @@ -0,0 +1,53 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairAccessTest { + AccountService accounts;ReviewRepairWorkflow workflow;RawReviewSnapshotReader reader;ReviewIsolationExecutor isolation;ReviewRepairAccess access;User actor; + ReviewRepairAccess.Position position=new ReviewRepairAccess.Position("STRING","p",0); + @BeforeEach void setup() { + accounts=mock(AccountService.class);workflow=mock(ReviewRepairWorkflow.class);reader=mock(RawReviewSnapshotReader.class);isolation=mock(ReviewIsolationExecutor.class); + access=new ReviewRepairAccess(accounts,workflow,reader,isolation);actor=user("actor"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(actor,null,List.of())); + when(accounts.getCurrentUser(any())).thenReturn(actor); + } + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + static User user(String id){var user=new User();user.setId(id);user.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ,AdminPermission.PROJECT_VERSION_RESCAN));return user;} + @Test void neitherReadNorRescanAloneAuthorizesAnyRepairOperation() { + var prepared=new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(),"a".repeat(64),1,2); + for(var permissions:List.of(Set.of(AdminPermission.PROJECT_REVIEW_READ),Set.of(AdminPermission.PROJECT_VERSION_RESCAN),Set.of(AdminPermission.PROJECT_REVIEW_DECIDE))) { + actor.setAdminPermissions(permissions);assertThrows(SecurityException.class,()->access.closeExpired(prepared));assertThrows(SecurityException.class,()->access.operations(null,25));assertThrows(SecurityException.class,()->access.recover(prepared.id()));assertThrows(SecurityException.class,()->access.capability()); + assertThrows(SecurityException.class,()->access.inspect(null));assertThrows(SecurityException.class,()->access.prepare(null)); + assertThrows(SecurityException.class,()->access.execute(prepared));assertThrows(SecurityException.class,()->access.receipt(prepared)); + } + verifyNoInteractions(workflow,reader,isolation); + } + @Test void preparationUsesServerActorAndRechecksFreshAccountIdentity() { + var id=UUID.randomUUID().toString(); + when(workflow.prepare(any(),any())).thenAnswer(i->{ + var input=i.getArgument(0);assertEquals("actor",input.actorId());assertEquals(ReviewSnapshotArchive.Action.ISOLATE_REVIEW,input.action()); + var allowed=i.getArgument(1);assertTrue(allowed.getAsBoolean()); + when(accounts.getCurrentUser(any())).thenReturn(user("other"));assertFalse(allowed.getAsBoolean()); + return new ReviewRepairPreparation.Prepared(id,"a".repeat(64),1,2); + }); + assertEquals(id,access.prepare(new ReviewRepairAccess.Prepare(id,position,"v","a".repeat(64))).id()); + } + @Test void deletedOrMissingAccountsCannotReachWorkflow() { + actor.setDeletedAt(java.time.LocalDateTime.now());assertThrows(SecurityException.class,()->access.inspect(null)); + when(accounts.getCurrentUser(any())).thenReturn(null);assertThrows(SecurityException.class,()->access.inspect(null));verifyNoInteractions(workflow); + } + @Test void malformedIdentityAndIntentAreRejectedBeforeWork() { + for(var p:List.of(new ReviewRepairAccess.Position("OBJECT_ID","ABCDEFABCDEFABCDEFABCDEF",0),new ReviewRepairAccess.Position("NUMBER","1",0),new ReviewRepairAccess.Position("STRING","p",-1))) + assertThrows(IllegalArgumentException.class,()->access.inspect(new ReviewRepairAccess.Inspect(p,"v"))); + assertThrows(IllegalArgumentException.class,()->access.execute(new ReviewRepairPreparation.Prepared("invalid","a".repeat(64),1,2))); + assertThrows(IllegalArgumentException.class,()->access.receipt(new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(),"a".repeat(64),2,1)));verifyNoInteractions(workflow,reader,isolation); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java new file mode 100644 index 000000000..b3f6fb64c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java @@ -0,0 +1,118 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairClosureTest { + ReviewIsolationExecutorTest fixture=new ReviewIsolationExecutorTest(); + @BeforeEach void setup(){fixture.setup();} + @AfterEach void cleanup(){fixture.cleanup();} + ReviewRepairPreparation.Prepared intent(long expires) { + var capture=fixture.reader.capture("p",0,"v");String id=UUID.randomUUID().toString();long created=expires-10000; + fixture.archive.retain(capture.forArchive(id,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,created,expires)); + return new ReviewRepairPreparation.Prepared(id,capture.sha256(),created,expires); + } + void record(ReviewRepairPreparation.Prepared p,String state) { + var d=new Document("_id",p.id()).append("token",UUID.randomUUID().toString()).append("actor","actor").append("action","ISOLATE_REVIEW") + .append("sha256",p.sha256()).append("createdAt",p.createdAt()).append("expiresAt",p.expiresAt()).append("state",state); + if(!state.equals("RESERVED"))d.append("startedAt",new Date(p.createdAt())); + if(state.equals("UNKNOWN"))d.append("uncertainAt",new Date(p.createdAt()+1)); + fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).insertOne(d); + } + void expire(ReviewRepairPreparation.Prepared p)throws InterruptedException { + long wait=p.expiresAt()-System.currentTimeMillis()+25;if(wait>0)Thread.sleep(wait); + } + @ParameterizedTest @ValueSource(strings={"RESERVED","EXECUTING","UNKNOWN"}) + void closesExpiredClaimsWithoutMutatingVersionOrRenewingIntent(String state) { + var p=intent(System.currentTimeMillis()-1000);record(p,state);var before=fixture.version(); + var receipt=fixture.executor().closeExpired(p,"actor",()->true);assertEquals("NOT_APPLIED",receipt.outcome().state());assertEquals(before,fixture.version()); + var saved=fixture.operation();var resolution=saved.get("resolution",Document.class);assertEquals("EXPIRED_CLAIM_CLOSED",resolution.get("kind"));assertEquals(state,resolution.get("previousState")); + assertEquals("actor",resolution.get("actorId"));assertInstanceOf(Date.class,resolution.get("closedAt"));assertEquals(saved.get("finishedAt"),resolution.get("closedAt")); + assertEquals(p.expiresAt(),saved.get("expiresAt"));assertEquals(receipt,fixture.executor().closeExpired(p,"actor",()->true));assertEquals(saved,fixture.operation()); + assertNull(fixture.journal.claim(p,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true));assertEquals(before,fixture.version()); + } + @Test void unexpiredAbsentMalformedAndWrongActorCannotClose() { + var p=intent(System.currentTimeMillis()+60000);assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertNull(fixture.operation()); + record(p,"EXECUTING");var before=fixture.operation();assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(before,fixture.operation()); + assertThrows(SecurityException.class,()->fixture.executor().closeExpired(p,"other",()->true)); + assertThrows(SecurityException.class,()->fixture.executor().closeExpired(p,"actor",()->false));assertEquals(before,fixture.operation()); + var altered=new ReviewRepairPreparation.Prepared(p.id(),"f".repeat(64),p.createdAt(),p.expiresAt());assertThrows(IllegalStateException.class,()->fixture.executor().closeExpired(altered,"actor",()->true)); + } + @Test void malformedExpiredRecordAndNumericTypeSubstitutionStayUnknown() { + var p=intent(System.currentTimeMillis()-1000);record(p,"EXECUTING");var records=fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION); + records.updateOne(new Document("_id",p.id()),new Document("$set",new Document("extra","unexpected")));var before=fixture.operation(); + assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(before,fixture.operation()); + records.updateOne(new Document("_id",p.id()),new Document("$unset",new Document("extra","")).append("$set",new Document("createdAt",(double)p.createdAt())));before=fixture.operation(); + assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(before,fixture.operation()); + } + @ParameterizedTest @ValueSource(strings={"beforeSnapshot","afterVersionWrite"}) + void closureWinsAgainstAnOldTransactionAndPreventsItsVersionCommit(String phase)throws Exception { + var p=intent(System.currentTimeMillis()+1000);var before=fixture.version();var reader=spy(fixture.reader);var closed=new AtomicBoolean(); + doAnswer(i->{ + var value=(RawReviewSnapshotReader.Captured)i.callRealMethod(); + boolean selected=phase.equals("beforeSnapshot") || !value.sha256().equals(p.sha256()); + if(selected && closed.compareAndSet(false,true)){expire(p);assertEquals("NOT_APPLIED",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());} + return value; + }).when(reader).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + var outcome=new ReviewIsolationExecutor(fixture.mongo,fixture.archive,reader,fixture.journal).execute(p,"actor",()->true); + assertTrue(closed.get());assertEquals("NOT_APPLIED",outcome.state());assertEquals(before,fixture.version());assertEquals("EXPIRED_CLAIM_CLOSED",fixture.operation().get("resolution",Document.class).get("kind")); + } + @Test void lateExecutorStartingAfterClosureCannotExecute()throws Exception { + var p=intent(System.currentTimeMillis()+1000);var claim=fixture.journal.claim(p,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true);assertNotNull(claim);expire(p); + var before=fixture.version();assertEquals("NOT_APPLIED",fixture.executor().closeExpired(p,"actor",()->true).outcome().state()); + assertEquals("NOT_APPLIED",fixture.executor().execute(p,"actor",()->true).state());assertEquals(before,fixture.version()); + } + @Test void lostClosureAcknowledgementRecoversWithoutASecondWrite() { + var p=intent(System.currentTimeMillis()-1000);record(p,"UNKNOWN");var journal=spy(fixture.journal);var writes=new AtomicInteger(); + doAnswer(i->{i.callRealMethod();writes.incrementAndGet();throw new MongoException("lost acknowledgement");}).when(journal).closeExpired(eq(p),eq("actor"),any()); + var executor=new ReviewIsolationExecutor(fixture.mongo,fixture.archive,fixture.reader,journal);var before=fixture.version(); + assertEquals("NOT_APPLIED",executor.closeExpired(p,"actor",()->true).outcome().state());assertEquals(1,writes.get());assertEquals(before,fixture.version()); + } + @Test void anAlreadyCommittedIsolationReceiptIsNeverOverwritten() { + var p=fixture.prepare();assertEquals("APPLIED",fixture.executor().execute(p,"actor",()->true).state());var before=fixture.version();var operation=fixture.operation(); + assertEquals("APPLIED",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(operation,fixture.operation());assertEquals(before,fixture.version()); + } + @Test void committedTransactionWinsWhileClosureWaitsOnTheJournalWrite()throws Exception { + var p=intent(System.currentTimeMillis()+1000);var sent=new CountDownLatch(1); + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031"); + var settings=MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=5000")) + .addCommandListener(new com.mongodb.event.CommandListener(){@Override public void commandStarted(com.mongodb.event.CommandStartedEvent e){ + if(e.getCommandName().equals("update") && e.getCommand().toJson().contains("EXPIRED_CLAIM_CLOSED"))sent.countDown(); + }}).build(); + try(var client=MongoClients.create(settings);var worker=Executors.newSingleThreadExecutor()) { + var mongo=new org.springframework.data.mongodb.core.MongoTemplate(client,fixture.mongo.getDb().getName()); + var closer=new ReviewIsolationExecutor(mongo,fixture.archive,fixture.reader,new ReviewRepairJournal(mongo,fixture.archive)); + var wrapped=spy(fixture.mongo);var factory=spy(fixture.mongo.getMongoDatabaseFactory());doReturn(factory).when(wrapped).getMongoDatabaseFactory(); + var future=new AtomicReference>(); + doAnswer(i->{var session=spy((ClientSession)i.callRealMethod());doAnswer(c->{ + expire(p);future.set(worker.submit(()->closer.closeExpired(p,"actor",()->true))); + assertTrue(sent.await(5,TimeUnit.SECONDS));return c.callRealMethod(); + }).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ReviewIsolationExecutor(wrapped,fixture.archive,fixture.reader,fixture.journal).execute(p,"actor",()->true); + assertEquals("APPLIED",result.state());assertEquals("APPLIED",future.get().get(10,TimeUnit.SECONDS).outcome().state()); + assertNull(fixture.operation().get("resolution"));assertEquals(result.afterSha256(),fixture.reader.capture("p",0,"v").sha256()); + } + } + @Test void permissionLossAtTheFinalJournalCheckpointPreventsClosure() { + var p=intent(System.currentTimeMillis()-1000);record(p,"UNKNOWN");var before=fixture.operation();var checks=new AtomicInteger(); + assertThrows(SecurityException.class,()->fixture.journal.closeExpired(p,"actor",()->checks.incrementAndGet()==1));assertEquals(before,fixture.operation()); + } + @Test void standaloneIsRejectedBeforeClosingAClaim() { + var p=intent(System.currentTimeMillis()-1000);record(p,"UNKNOWN");var before=fixture.operation();var mongo=spy(fixture.mongo);var db=spy(fixture.mongo.getDb());doReturn(db).when(mongo).getDb(); + doReturn(new Document("isWritablePrimary",true)).when(db).runCommand(eq(new Document("hello",1)),eq(ReadPreference.primary())); + var executor=new ReviewIsolationExecutor(mongo,fixture.archive,fixture.reader,fixture.journal); + assertThrows(IllegalStateException.class,()->executor.closeExpired(p,"actor",()->true));assertEquals(before,fixture.operation()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java new file mode 100644 index 000000000..db211f1bc --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java @@ -0,0 +1,48 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairConfigurationTest { + String key=Base64.getEncoder().encodeToString(new byte[32]); + @Test void featureIsAbsentByDefault() { + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).run(context->{assertFalse(context.containsBean("reviewRepairPreparation"));assertFalse(context.containsBean("reviewRepairWorkflow"));assertFalse(context.containsBean("projectMutationWorkflow"));}); + } + @Test void enabledFeatureRequiresExplicitKeys() { + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).withBean(MongoTemplate.class,()->mock(MongoTemplate.class)).withBean(net.modtale.service.user.account.AccountService.class,()->mock(net.modtale.service.user.account.AccountService.class)) + .withPropertyValues("app.warden.repair.enabled=true").run(context->assertNotNull(context.getStartupFailure())); + } + @Test void configuredFeatureConstructsBoundedPreparationWithoutStartingWork() { + var mongo=mock(MongoTemplate.class,RETURNS_DEEP_STUBS); + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).withBean(MongoTemplate.class,()->mongo).withBean(net.modtale.service.user.account.AccountService.class,()->mock(net.modtale.service.user.account.AccountService.class)) + .withBean(net.modtale.service.security.access.AccessControlService.class,()->mock(net.modtale.service.security.access.AccessControlService.class)) + .withBean(net.modtale.repository.user.ApiKeyRepository.class,()->mock(net.modtale.repository.user.ApiKeyRepository.class)) + .withPropertyValues("app.warden.repair.enabled=true","app.warden.repair.active-key=test","app.warden.repair.signing-keys.test="+key) + .run(context->{assertNull(context.getStartupFailure());assertNotNull(context.getBean(ReviewRepairPreparation.class));assertNotNull(context.getBean(ReviewSnapshotArchive.class));assertNotNull(context.getBean(ReviewRepairWorkflow.class));assertNotNull(context.getBean(ProjectMutationWorkflow.class));assertNotNull(context.getBean(ProjectMutationOwnerAuthority.class));assertNotNull(context.getBean(ProjectMutationReferenceReader.class));}); + } + @Test void remoteAdmissionUsesTheExistingRuntimeWithoutDispatching() { + var mongo=mock(MongoTemplate.class,RETURNS_DEEP_STUBS);var remote=mock(net.modtale.service.security.scan.RemoteReviewClient.class); + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).withBean(MongoTemplate.class,()->mongo) + .withBean(net.modtale.service.security.scan.RemoteReviewClient.class,()->remote) + .withBean(net.modtale.service.user.account.AccountService.class,()->mock(net.modtale.service.user.account.AccountService.class)) + .withBean(net.modtale.service.security.access.AccessControlService.class,()->mock(net.modtale.service.security.access.AccessControlService.class)) + .withBean(net.modtale.repository.user.ApiKeyRepository.class,()->mock(net.modtale.repository.user.ApiKeyRepository.class)) + .withPropertyValues("app.warden.repair.enabled=true","app.warden.jobs.enabled=true","app.warden.repair.active-key=test","app.warden.repair.signing-keys.test="+key) + .run(context->{assertNull(context.getStartupFailure());assertNotNull(context.getBean(ProjectMutationAdmissionPreparation.class));assertNotNull(context.getBean(ProjectMutationActivator.class));assertNotNull(context.getBean(ProjectMutationAutomaticAdmission.class));assertNotNull(context.getBean(ProjectMutationPriorWorkReader.class));verifyNoInteractions(remote);}); + } + @Test void invalidKeysAndLimitsFailWithoutEchoingSecretValues() { + for(String value:List.of("secret",Base64.getEncoder().encodeToString(new byte[31]),key.replace("=",""))) { + var failure=assertThrows(IllegalArgumentException.class,()->new AppReviewRepairProperties(true,"test",Map.of("test",value),1,1000)); + assertFalse(failure.getMessage().contains(value)); + } + assertThrows(IllegalArgumentException.class,()->new AppReviewRepairProperties(true,"test",Map.of("test",key),3,1000)); + assertThrows(IllegalArgumentException.class,()->new AppReviewRepairProperties(true,"test",Map.of("test",key),1,900001)); + var properties=new AppReviewRepairProperties(true,"test",Map.of("test",key),1,1000);assertFalse(properties.toString().contains(key)); + var decoded=properties.decodedKeys();decoded.get("test")[0]=7;assertEquals(0,properties.decodedKeys().get("test")[0]); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java new file mode 100644 index 000000000..be6602700 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java @@ -0,0 +1,148 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import com.mongodb.event.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.net.*; +import java.io.*; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairIoStallTest { + MongoClient direct,client;MongoTemplate mongo;ReplyProxy proxy;ReviewSnapshotArchive archive;String database; + AtomicBoolean stallInsert=new AtomicBoolean(),stallWrite=new AtomicBoolean();AtomicLong remaining=new AtomicLong(TimeUnit.SECONDS.toNanos(2));AtomicBoolean expireAfterInsert=new AtomicBoolean(); + @BeforeEach void setup(TestInfo info)throws Exception { + boolean smallBuffers=info.getTestMethod().orElseThrow().getName().startsWith("blockedSocketWrite"); + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + direct=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000&socketTimeoutMS=5000"); + proxy=new ReplyProxy(Integer.parseInt(port),smallBuffers);database="warden_io_"+UUID.randomUUID().toString().replace("-",""); + client=MongoClients.create(MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://127.0.0.1:"+proxy.port()+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=0")) + .applyToSocketSettings(settings->settings.sendBufferSize(smallBuffers?1024:0)) + .addCommandListener(new CommandListener() { + @Override public void commandStarted(CommandStartedEvent event){if(event.getCommandName().equals("insert")) {if(stallInsert.get())proxy.paused.set(true);if(stallWrite.get())proxy.pausedRequests.set(true);}} + @Override public void commandSucceeded(CommandSucceededEvent event){if(event.getCommandName().equals("insert") && expireAfterInsert.get())remaining.set(0);} + }).build()); + mongo=new MongoTemplate(client,database);mongo.getDb().withTimeout(3000,TimeUnit.MILLISECONDS).runCommand(new Document("ping",1)); + archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32])); + } + @AfterEach void cleanup()throws Exception { + if(proxy!=null)proxy.close();if(client!=null)client.close(); + if(direct!=null){direct.getDatabase(database).drop();direct.close();} + } + ReviewSnapshotArchive.Snapshot snapshot(int size){long now=System.currentTimeMillis();return new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),"p",0,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,now,now+60000,new byte[size]);} + + @Test void dependencyInventoryDeadlineBoundsAnActualStalledMongoReply() throws Exception { + var directMongo=new MongoTemplate(direct,database); + directMongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of( + new Document("_id","v").append("versionNumber","1").append("hash","a".repeat(64)).append("fileUrl","file")))); + assertEquals(net.modtale.service.security.scan.DependencyReviewGraph.State.FOUND, + new net.modtale.service.security.scan.DependencyReviewSource(mongo).readRoot("p","v").state()); + proxy.paused.set(true);long started=System.nanoTime(); + try(var worker=Executors.newSingleThreadExecutor()) { + var pending=worker.submit(()->new net.modtale.service.security.scan.DependencyReviewSource(mongo).readRoot("p","v")); + assertTrue(proxy.withheld.await(3,TimeUnit.SECONDS)); + assertEquals(net.modtale.service.security.scan.DependencyReviewGraph.State.UNAVAILABLE,pending.get(8,TimeUnit.SECONDS).state()); + assertTrue(System.nanoTime()-startedworkflow.prepare(request,()->true)); + try { + assertTrue(proxy.withheld.await(5,TimeUnit.SECONDS));assertFalse(pending.isDone()); + assertEquals(new ReviewRepairWorkflow.Status("OPEN",1),workflow.status()); + assertThrows(IllegalStateException.class,()->workflow.isolate(new ReviewRepairPreparation.Prepared(request.id(),request.expectedSha256(),1,2),"actor",()->true)); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1),workflow.status()); + var failure=assertThrows(ExecutionException.class,()->pending.get(10,TimeUnit.SECONDS));assertInstanceOf(MongoOperationTimeoutException.class,failure.getCause()); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status());verifyNoInteractions(isolation); + } finally {proxy.close();workflow.close();} + } + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + + @Test void lostInsertReplyLeavesOnlyUnpublishedChunkAndNeverClaimsRetention() { + var snapshot=snapshot(5);stallInsert.set(true);long started=System.nanoTime(); + try(var io=ReviewRepairIo.open(()->TimeUnit.SECONDS.toNanos(1)-(System.nanoTime()-started))) { + assertThrows(RuntimeException.class,()->archive.retain(snapshot)); + } + assertTrue(proxy.paused.get());assertEquals(1,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + assertTrue(System.nanoTime()-startedarchive.retain(snapshot));} + assertEquals(0,remaining.get());assertEquals(1,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + + + @Test void blockedSocketWriteRetainsAdmissionBeyondCheckpointDeadlineUntilTransportCloses()throws Exception { + var directMongo=new MongoTemplate(direct,database); + directMongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("padding",new org.bson.types.Binary(new byte[300000]))))); + var expected=new RawReviewSnapshotReader(directMongo).capture("p",0,"v");var reader=new RawReviewSnapshotReader(mongo); + var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000);var isolation=mock(ReviewIsolationExecutor.class); + var workflow=new ReviewRepairWorkflow(preparation,isolation,1,1000,System::nanoTime); + var request=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",expected.sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + stallWrite.set(true); + try(var worker=Executors.newSingleThreadExecutor()) { + var pending=worker.submit(()->workflow.prepare(request,()->true)); + try { + assertTrue(proxy.requestWithheld.await(5,TimeUnit.SECONDS)); + // The pinned synchronous driver does not interrupt a blocked socket write at timeoutMS. + assertThrows(TimeoutException.class,()->pending.get(1500,TimeUnit.MILLISECONDS)); + assertEquals(new ReviewRepairWorkflow.Status("OPEN",1,1),workflow.status());workflow.close(); + assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1,1),workflow.status()); + proxy.close();assertThrows(ExecutionException.class,()->pending.get(10,TimeUnit.SECONDS)); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status());verifyNoInteractions(isolation); + } finally {proxy.close();workflow.close();} + } + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + + /** Transparent loopback proxy that can retain replies while requests still reach the real database. */ + static final class ReplyProxy implements AutoCloseable { + final ServerSocket listener;final ExecutorService workers=Executors.newVirtualThreadPerTaskExecutor();final List sockets=new CopyOnWriteArrayList<>(); + final AtomicBoolean paused=new AtomicBoolean(),pausedRequests=new AtomicBoolean(),closed=new AtomicBoolean();final CountDownLatch withheld=new CountDownLatch(1),requestWithheld=new CountDownLatch(1),release=new CountDownLatch(1); + ReplyProxy(int target)throws IOException {this(target,false);} + ReplyProxy(int target,boolean smallBuffers)throws IOException { + listener=new ServerSocket(0,16,InetAddress.getLoopbackAddress()); + workers.submit(()->{while(!closed.get())try { + Socket front=listener.accept();if(smallBuffers)front.setReceiveBufferSize(1024);sockets.add(front);Socket back=new Socket("127.0.0.1",target);sockets.add(back); + workers.submit(()->copy(front,back,false));workers.submit(()->copy(back,front,true)); + }catch(IOException failure){if(!closed.get())throw new UncheckedIOException(failure);}}); + } + int port(){return listener.getLocalPort();} + void copy(Socket source,Socket target,boolean reply) { + try {byte[] buffer=new byte[8192];int count; + while((count=source.getInputStream().read(buffer))!=-1) { + if(reply && paused.get()){withheld.countDown();release.await();} + if(!reply && pausedRequests.get()){requestWithheld.countDown();release.await();} + if(closed.get())return;target.getOutputStream().write(buffer,0,count);target.getOutputStream().flush(); + } + }catch(IOException ignored){}catch(InterruptedException interrupted){Thread.currentThread().interrupt();} + } + @Override public void close() { + if(!closed.compareAndSet(false,true))return;release.countDown();try{listener.close();}catch(IOException ignored){} + for(var socket:sockets)try{socket.close();}catch(IOException ignored){} + workers.shutdownNow(); + try{if(!workers.awaitTermination(5,TimeUnit.SECONDS))throw new IllegalStateException("Proxy did not stop");}catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new IllegalStateException(interrupted);} + } + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java new file mode 100644 index 000000000..489035f5a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java @@ -0,0 +1,63 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairIoTest { + @Test void remainingBudgetIsAppliedAndNeverBecomesUnlimited() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF);var remaining=new AtomicLong(TimeUnit.SECONDS.toNanos(30)); + try(var io=ReviewRepairIo.open(remaining::get)) { + ReviewRepairIo.collection(collection);verify(collection).withTimeout(5000,TimeUnit.MILLISECONDS); + remaining.set(1);ReviewRepairIo.collection(collection);verify(collection).withTimeout(1,TimeUnit.MILLISECONDS); + remaining.set(0);assertThrows(IllegalStateException.class,()->ReviewRepairIo.collection(collection)); + } + clearInvocations(collection);assertSame(collection,ReviewRepairIo.collection(collection));verifyNoInteractions(collection); + } + @Test void cleanupHasIndependentBudgetAndRestoresExpiredScope() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF); + try(var expired=ReviewRepairIo.open(()->0)) { + try(var cleanup=ReviewRepairIo.cleanup()) {assertSame(collection,ReviewRepairIo.collection(collection));} + assertThrows(IllegalStateException.class,()->ReviewRepairIo.collection(collection)); + } + } + @Test void scopeDoesNotEscapeToAnotherThread()throws Exception { + MongoCollection collection=mock(MongoCollection.class); + try(var io=ReviewRepairIo.open(()->0);var worker=java.util.concurrent.Executors.newSingleThreadExecutor()) { + assertSame(collection,worker.submit(()->ReviewRepairIo.collection(collection)).get(5,TimeUnit.SECONDS));verifyNoInteractions(collection); + } + } + @Test void nestedWorkCannotExtendItsParentDeadline() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF); + var parent=new AtomicLong(TimeUnit.MILLISECONDS.toNanos(75)); + try(var outer=ReviewRepairIo.open(parent::get);var inner=ReviewRepairIo.open(()->TimeUnit.SECONDS.toNanos(30))) { + ReviewRepairIo.collection(collection);verify(collection).withTimeout(75,TimeUnit.MILLISECONDS); + parent.set(1);ReviewRepairIo.collection(collection);verify(collection).withTimeout(1,TimeUnit.MILLISECONDS); + assertEquals(1,inner.remainingNanos()); + parent.set(0);assertEquals(0,inner.remainingNanos());assertThrows(IllegalStateException.class,()->ReviewRepairIo.collection(collection)); + assertThrows(IllegalStateException.class,ReviewRepairIo::sessionOptions); + assertThrows(IllegalStateException.class,()->ReviewRepairIo.transactionOptions(com.mongodb.TransactionOptions.builder().build())); + } + } + @Test void shorterChildBudgetExpiresWithoutChangingParent() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF);var child=new AtomicLong(TimeUnit.MILLISECONDS.toNanos(20)); + try(var parent=ReviewRepairIo.open(()->TimeUnit.SECONDS.toNanos(30))) { + try(var inner=ReviewRepairIo.open(child::get)) { + ReviewRepairIo.collection(collection);verify(collection).withTimeout(20,TimeUnit.MILLISECONDS); + child.set(0);assertThrows(IllegalStateException.class,()->ReviewRepairIo.collection(collection)); + } + ReviewRepairIo.collection(collection);verify(collection).withTimeout(5000,TimeUnit.MILLISECONDS); + } + } + @Test void nestedOrdinaryScopeInsideCleanupRemainsBoundedByCleanup() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF); + try(var parent=ReviewRepairIo.open(()->0);var cleanup=ReviewRepairIo.cleanup();var inner=ReviewRepairIo.open(()->1)) { + ReviewRepairIo.collection(collection);verify(collection).withTimeout(1,TimeUnit.MILLISECONDS); + } + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java new file mode 100644 index 000000000..ba8b9a00d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java @@ -0,0 +1,81 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairJournalTest { + MongoClient client;MongoTemplate mongo;ReviewSnapshotArchive archive;ReviewRepairJournal journal;ReviewRepairPreparation.Prepared prepared; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");mongo=new MongoTemplate(client,"warden_repair_journal_"+UUID.randomUUID().toString().replace("-","")); + mongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("scanResult","malformed")))); + archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));var reader=new RawReviewSnapshotReader(mongo); + var request=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + prepared=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000).prepare(request,()->true);journal=new ReviewRepairJournal(mongo,archive); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewRepairJournal.Claim claim(ReviewRepairJournal j){return j.claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true);} + @Test void restartCannotReclaimExecutingOrUncertainOperation() { + var before=mongo.getCollection("projects").find().first();var first=claim(journal);assertNotNull(first); + assertNull(claim(new ReviewRepairJournal(mongo,archive)));assertTrue(journal.markUnknown(first,()->true));assertTrue(journal.markUnknown(first,()->true)); + assertNull(claim(journal));assertFalse(journal.markUnknown(new ReviewRepairJournal.Claim(first.id(),UUID.randomUUID().toString()),()->true)); + assertEquals(before,mongo.getCollection("projects").find().first()); + } + @Test void concurrentClaimantsHaveOnlyOneWinner()throws Exception { + try(var executor=Executors.newFixedThreadPool(2)) { + var a=executor.submit(()->claim(new ReviewRepairJournal(mongo,archive)));var b=executor.submit(()->claim(new ReviewRepairJournal(mongo,archive))); + var first=a.get(5,TimeUnit.SECONDS);var second=b.get(5,TimeUnit.SECONDS);assertNotEquals(first==null,second==null); + } + assertEquals(1,mongo.getCollection(ReviewRepairJournal.COLLECTION).countDocuments()); + } + @Test void expiredPreparedEvidenceCannotArmExecution() { + var old=archive.load(prepared.id());var expired=new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),old.projectId(),old.versionIndex(),old.actorId(),old.action(),1,2,old.versionBytes());archive.retain(expired); + prepared=new ReviewRepairPreparation.Prepared(expired.id(),prepared.sha256(),1,2);assertNull(claim(journal)); + assertEquals("RESERVED",mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first().getString("state"));assertNull(claim(journal)); + } + @ParameterizedTest @ValueSource(strings={"actor","action","hash","expiry"}) + void originalArchiveBindingCannotBeForged(String mutation) { + var request=mutation.equals("hash")?new ReviewRepairPreparation.Prepared(prepared.id(),"a".repeat(64),prepared.createdAt(),prepared.expiresAt()): + mutation.equals("expiry")?new ReviewRepairPreparation.Prepared(prepared.id(),prepared.sha256(),prepared.createdAt(),prepared.expiresAt()+1):prepared; + assertThrows(IllegalStateException.class,()->journal.claim(request,mutation.equals("actor")?"other":"actor",mutation.equals("action")?ReviewSnapshotArchive.Action.REPLACE_REVIEW:ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true)); + assertEquals(0,mongo.getCollection(ReviewRepairJournal.COLLECTION).countDocuments()); + } + MongoCollection intercepted(MongoTemplate template) { + var configured=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + var spy=spy(configured);doReturn(spy).when(template).getCollection(ReviewRepairJournal.COLLECTION);doReturn(spy).when(spy).withReadPreference(any());doReturn(spy).when(spy).withReadConcern(any());doReturn(spy).when(spy).withWriteConcern(any());return spy; + } + @Test void committedReservationWithLostAcknowledgementCanArmOnlyItsOwnToken() { + var template=spy(mongo);var ops=intercepted(template);doAnswer(i->{i.callRealMethod();throw new MongoException("lost acknowledgement");}).when(ops).insertOne(any(Document.class)); + var result=claim(new ReviewRepairJournal(template,archive));assertNotNull(result);assertNull(claim(journal)); + } + @Test void committedArmingWithLostAcknowledgementRecoversOwnClaim() { + var template=spy(mongo);var ops=intercepted(template); + doAnswer(i->{i.callRealMethod();throw new MongoException("lost acknowledgement");}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + var result=claim(new ReviewRepairJournal(template,archive));assertNotNull(result);assertNull(claim(journal)); + } + @Test void unknownOutcomeWithLostAcknowledgementIsDurableAndNeverReclaimed() { + var own=claim(journal);assertNotNull(own);var template=spy(mongo);var ops=intercepted(template); + doAnswer(i->{i.callRealMethod();throw new MongoException("lost acknowledgement");}).when(ops).updateOne(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.UpdateOptions.class)); + assertTrue(new ReviewRepairJournal(template,archive).markUnknown(own,()->true));assertNull(claim(journal)); + assertEquals("UNKNOWN",mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first().getString("state")); + } + @Test void revocationAfterReservationLeavesNoReusableExecutionAuthority() { + var allowed=new AtomicBoolean(true);var template=spy(mongo);var ops=intercepted(template); + doAnswer(i->{var result=i.callRealMethod();allowed.set(false);return result;}).when(ops).insertOne(any(Document.class)); + assertThrows(SecurityException.class,()->new ReviewRepairJournal(template,archive).claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,allowed::get)); + assertEquals("RESERVED",mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first().getString("state"));assertNull(claim(journal)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java new file mode 100644 index 000000000..673539934 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java @@ -0,0 +1,49 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairOperationReaderTest { + MongoClient client;MongoTemplate mongo;MongoCollection operations;ReviewRepairOperationReader reader; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000&socketTimeoutMS=5000");mongo=new MongoTemplate(client,"warden_operation_page_"+UUID.randomUUID().toString().replace("-","")); + mongo.getDb().createCollection(ReviewRepairJournal.COLLECTION,new com.mongodb.client.model.CreateCollectionOptions().collation(com.mongodb.client.model.Collation.builder().locale("en").collationStrength(com.mongodb.client.model.CollationStrength.SECONDARY).build())); + operations=mongo.getCollection(ReviewRepairJournal.COLLECTION);reader=new ReviewRepairOperationReader(mongo);reader.initialize(); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + String id(int n){return String.format("%08x-0000-0000-0000-%012x",n,n);} + Document record(int n,Object actor,Object action,Object state){return new Document("_id",id(n)).append("actor",actor).append("action",action).append("state",state);} + @Test void discoveryIsActorScopedBinaryAndScalar() { + operations.insertMany(List.of(record(1,"actor","ISOLATE_REVIEW","EXECUTING"),record(2,"Actor","ISOLATE_REVIEW","APPLIED"),record(3,List.of("actor"),"ISOLATE_REVIEW","APPLIED"),record(4,"actor",List.of("ISOLATE_REVIEW"),"APPLIED"),record(5,"actor","REPLACE_REVIEW","APPLIED"))); + var page=reader.page("actor",null,25);assertEquals(List.of(new ReviewRepairOperationReader.Item(id(1),"EXECUTING")),page.items());assertNull(page.nextCursor());assertEquals("OPERATION_ID",page.order()); + } + @Test void pagesStayBoundedAndDeletedCursorsStillAdvance() { + for(int n=1;n<=4;n++)operations.insertOne(record(n,"actor","ISOLATE_REVIEW","UNKNOWN")); + var first=reader.page("actor",null,2);assertEquals(2,first.items().size());assertEquals("r1."+id(2),first.nextCursor()); + operations.deleteOne(new Document("_id",id(2)));var second=reader.page("actor",first.nextCursor(),2);assertEquals(List.of(id(3),id(4)),second.items().stream().map(ReviewRepairOperationReader.Item::id).toList());assertNull(second.nextCursor()); + assertTrue(reader.page("other",first.nextCursor(),2).items().isEmpty()); + } + @Test void malformedStateAndPrivatePayloadsNeverExpandTheResponse() { + var large="private".repeat(100000);operations.insertOne(record(1,"actor","ISOLATE_REVIEW",large).append("payload",large).append("token",large)); + var before=operations.find().first();var page=reader.page("actor",null,1);assertEquals(List.of(new ReviewRepairOperationReader.Item(id(1),"UNKNOWN")),page.items());assertEquals(before,operations.find().first());assertFalse(page.toString().contains("private")); + } + @Test void malformedIdentityDoesNotEnterTheReferenceStream() { + operations.insertOne(new Document("_id","INVALID").append("actor","actor").append("action","ISOLATE_REVIEW")); + operations.insertOne(record(1,"actor","ISOLATE_REVIEW","APPLIED"));assertEquals(1,reader.page("actor",null,25).items().size()); + } + @Test void invalidCursorsAndLimitsAreRejected() { + for(String cursor:List.of("",id(1),"r2."+id(1),"r1."+id(1)+"extra"))assertThrows(IllegalArgumentException.class,()->reader.page("actor",cursor,1)); + assertThrows(IllegalArgumentException.class,()->reader.page("actor",null,0));assertThrows(IllegalArgumentException.class,()->reader.page("actor",null,26)); + } + @Test void missingRequiredIndexDoesNotFallBackToACollectionScan() { + operations.insertOne(record(1,"actor","ISOLATE_REVIEW","UNKNOWN"));operations.dropIndex(ReviewRepairOperationReader.INDEX); + assertThrows(com.mongodb.MongoException.class,()->reader.page("actor",null,25)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java new file mode 100644 index 000000000..ee2b53001 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairPreparationTest { + MongoClient client;MongoTemplate mongo;RawReviewSnapshotReader reader;ReviewSnapshotArchive archive;Clock clock; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");mongo=new MongoTemplate(client,"warden_prepare_"+UUID.randomUUID().toString().replace("-","")); + mongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("scanResult","malformed")))); + reader=new RawReviewSnapshotReader(mongo);archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));clock=Clock.fixed(Instant.ofEpochMilli(1000),ZoneOffset.UTC); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewRepairPreparation.Request request(){return new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW);} + ReviewRepairPreparation service(ReviewSnapshotArchive a,RawReviewSnapshotReader r,Clock c){return new ReviewRepairPreparation(a,r,c,1,1000);} + @Test void retryAfterRestartUsesOriginalSnapshotAndExpiryWithoutProjectMutation() { + var req=request();var before=mongo.getCollection("projects").find().first();var result=service(archive,reader,clock).prepare(req,()->true); + var restarted=service(new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32])),new RawReviewSnapshotReader(mongo),Clock.fixed(Instant.ofEpochMilli(1500),ZoneOffset.UTC)); + assertEquals(result,restarted.prepare(req,()->true));assertEquals(2000,result.expiresAt());assertEquals(1,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(before,mongo.getCollection("projects").find().first()); + } + @ParameterizedTest @ValueSource(strings={"actor","action","project","hash"}) + void sameRequestIdentityCannotBeRebound(String field) { + var req=request();service(archive,reader,clock).prepare(req,()->true); + var replacement=new ReviewRepairPreparation.Request(req.id(),field.equals("project")?"other":req.projectId(),0,"v",field.equals("hash")?"a".repeat(64):req.expectedSha256(),field.equals("actor")?"other":req.actorId(),field.equals("action")?ReviewSnapshotArchive.Action.REPLACE_REVIEW:req.action()); + assertThrows(IllegalStateException.class,()->service(archive,reader,clock).prepare(replacement,()->true));assertEquals("actor",archive.load(req.id()).actorId()); + } + @Test void expiredIntentCannotBeRenewedByRetry() { + var req=request();service(archive,reader,clock).prepare(req,()->true); + assertThrows(IllegalStateException.class,()->service(archive,reader,Clock.fixed(Instant.ofEpochMilli(2000),ZoneOffset.UTC)).prepare(req,()->true));assertEquals(2000,archive.load(req.id()).expiresAt()); + } + @Test void staleExpectedDigestCannotArchiveNewerState() { + var req=request();mongo.getCollection("projects").updateOne(new Document(),new Document("$set",new Document("versions.0.changed",true))); + assertThrows(IllegalStateException.class,()->service(archive,reader,clock).prepare(req,()->true));assertEquals(0,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void changeAfterArchivalRetainsEvidenceButReturnsNoPreparedResult() { + var req=request();var intercepted=spy(archive);doAnswer(i->{var result=i.callRealMethod();mongo.getCollection("projects").updateOne(new Document(),new Document("$set",new Document("versions.0.changed",true)));return result;}).when(intercepted).retain(any()); + assertThrows(IllegalStateException.class,()->service(intercepted,reader,clock).prepare(req,()->true));assertNotNull(archive.load(req.id())); + assertThrows(IllegalStateException.class,()->service(archive,reader,clock).prepare(req,()->true)); + } + @Test void permissionLossAfterArchiveCannotReturnPreparedResult() { + var req=request();var allowed=new AtomicBoolean(true);var intercepted=spy(archive); + doAnswer(i->{var result=i.callRealMethod();allowed.set(false);return result;}).when(intercepted).retain(any()); + assertThrows(SecurityException.class,()->service(intercepted,reader,clock).prepare(req,allowed::get));assertNotNull(archive.load(req.id())); + var a=mock(ReviewSnapshotArchive.class);var r=mock(RawReviewSnapshotReader.class); + assertThrows(SecurityException.class,()->service(a,r,clock).prepare(req,()->false));verifyNoInteractions(a,r); + } + @Test void unknownReturnAfterArchiveCommitRecoversSameIntent() { + var req=request();var intercepted=spy(archive);doAnswer(i->{i.callRealMethod();throw new IllegalStateException("lost response");}).when(intercepted).retain(any()); + assertThrows(IllegalStateException.class,()->service(intercepted,reader,clock).prepare(req,()->true)); + assertEquals(req.id(),service(archive,reader,clock).prepare(req,()->true).id());assertEquals(1,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void localAdmissionHasNoUnboundedWaitQueue()throws Exception { + var req=request();var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var intercepted=spy(archive); + doAnswer(i->{entered.countDown();assertTrue(release.await(5,TimeUnit.SECONDS));return i.callRealMethod();}).when(intercepted).find(req.id()); + var service=service(intercepted,reader,clock);try(var executor=Executors.newSingleThreadExecutor()) { + var running=executor.submit(()->service.prepare(req,()->true));try {assertTrue(entered.await(5,TimeUnit.SECONDS));assertThrows(IllegalStateException.class,()->service.prepare(req,()->true));}finally{release.countDown();} + assertEquals(req.id(),running.get(5,TimeUnit.SECONDS).id()); + } + assertEquals(req.id(),service.prepare(req,()->true).id()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java new file mode 100644 index 000000000..dd29e12e2 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java @@ -0,0 +1,67 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairTransactionContractTest { + MongoClient client;MongoTemplate mongo;MongoCollection projects,operations;RawReviewSnapshotReader reader; + TransactionOptions options=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).writeConcern(WriteConcern.MAJORITY.withJournal(true)) + .readPreference(ReadPreference.primary()).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031");if(!Set.of("27031","27032").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=10000"); + mongo=new MongoTemplate(client,"warden_repair_tx_"+UUID.randomUUID().toString().replace("-","")); + projects=mongo.getCollection("projects");operations=mongo.getCollection("review_repair_operations"); + projects.insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("scanResult",new Document("status","SCANNING").append("scanAttempt",1))))); + operations.insertOne(new Document("_id","operation").append("state","EXECUTING"));reader=new RawReviewSnapshotReader(mongo); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + Document storedVersion(){return projects.find().first().getList("versions",Document.class).getFirst();} + void stage(ClientSession session) { + assertEquals(1,projects.updateOne(session,new Document("_id","p"),new Document("$set",new Document("versions.0.scanResult.status","FAILED"))).getModifiedCount()); + assertEquals(1,operations.updateOne(session,new Document("_id","operation"),new Document("$set",new Document("state","APPLIED"))).getModifiedCount()); + } + @Test void ordinaryEmbeddedEqualityDoesNotProveRawByteIdentity() { + var original=storedVersion();var before=reader.capture("p",0,"v"); + projects.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0.scanResult.scanAttempt",1L))); + assertInstanceOf(Long.class,storedVersion().get("scanResult",Document.class).get("scanAttempt")); + assertNotEquals(before.sha256(),reader.capture("p",0,"v").sha256()); + assertNotNull(projects.find(new Document("_id","p").append("versions",original)).first()); + } + @Test void concurrentTypeChangeAfterTransactionalCaptureCausesWriteConflict() { + var original=reader.capture("p",0,"v"); + try(var session=client.startSession()) { + session.startTransaction(options);assertEquals(original.sha256(),reader.capture(session,"p",0,"v").sha256()); + projects.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0.scanResult.scanAttempt",1L))); + assertEquals(original.sha256(),reader.capture(session,"p",0,"v").sha256()); + var conflict=assertThrows(MongoException.class,()->stage(session));assertTrue(conflict.hasErrorLabel("TransientTransactionError"));session.abortTransaction(); + } + assertEquals("SCANNING",storedVersion().get("scanResult",Document.class).getString("status"));assertEquals("EXECUTING",operations.find().first().getString("state")); + } + @Test void abortLeavesVersionAndOutcomeUnchanged() { + try(var session=client.startSession()) {session.startTransaction(options);reader.capture(session,"p",0,"v");stage(session);session.abortTransaction();} + assertEquals("SCANNING",storedVersion().get("scanResult",Document.class).getString("status"));assertEquals("EXECUTING",operations.find().first().getString("state")); + } + @Test void committedVersionAndOutcomeBecomeVisibleTogether() { + try(var session=client.startSession()) {session.startTransaction(options);reader.capture(session,"p",0,"v");stage(session);session.commitTransaction();} + assertEquals("FAILED",storedVersion().get("scanResult",Document.class).getString("status"));assertEquals("APPLIED",operations.find().first().getString("state")); + } + @Test void lostCommitAcknowledgementCanBeReconciledWithoutRepeatingWrites() { + try(var real=client.startSession()) { + var session=spy(real);session.startTransaction(options);reader.capture(session,"p",0,"v");stage(session); + doAnswer(i->{i.callRealMethod();var lost=new MongoException("lost commit acknowledgement");lost.addLabel("UnknownTransactionCommitResult");throw lost;}).when(session).commitTransaction(); + var failure=assertThrows(MongoException.class,session::commitTransaction);assertTrue(failure.hasErrorLabel("UnknownTransactionCommitResult")); + } + assertEquals("APPLIED",operations.withReadConcern(ReadConcern.MAJORITY).find().first().getString("state")); + assertEquals("FAILED",storedVersion().get("scanResult",Document.class).getString("status")); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java new file mode 100644 index 000000000..8e78ed658 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java @@ -0,0 +1,85 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import com.mongodb.event.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairTransactionStallTest { + MongoClient direct,client;MongoTemplate mongo;ReviewRepairIoStallTest.ReplyProxy proxy;String database; + ReviewSnapshotArchive archive;RawReviewSnapshotReader reader;ReviewRepairJournal journal;ReviewRepairPreparation preparation; + AtomicReference stallCommand=new AtomicReference<>();CountDownLatch commandEntered=new CountDownLatch(1); + @BeforeEach void setup()throws Exception { + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031");if(!Set.of("27031","27032").contains(port))throw new IllegalArgumentException(); + direct=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=5000"); + proxy=new ReviewRepairIoStallTest.ReplyProxy(Integer.parseInt(port));database="warden_tx_stall_"+UUID.randomUUID().toString().replace("-",""); + client=MongoClients.create(MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://127.0.0.1:"+proxy.port()+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=0")) + .addCommandListener(new CommandListener() { + @Override public void commandStarted(CommandStartedEvent event) { + if(event.getCommandName().equals(stallCommand.get())){proxy.paused.set(true);commandEntered.countDown();} + } + }).build()); + mongo=new MongoTemplate(client,database);mongo.getDb().withTimeout(3000,TimeUnit.MILLISECONDS).runCommand(new Document("ping",1)); + direct.getDatabase(database).getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("reviewStatus","PENDING") + .append("scanResult",new Document("status","SCANNING").append("scanState","REMOTE_REVIEW").append("verdict","BLOCK"))))); + archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));reader=new RawReviewSnapshotReader(mongo);journal=new ReviewRepairJournal(mongo,archive); + preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000); + } + @AfterEach void cleanup() { + if(proxy!=null)proxy.close();if(client!=null)client.close();if(direct!=null){direct.getDatabase(database).drop();direct.close();} + } + ReviewRepairWorkflow workflow(RawReviewSnapshotReader selected) { + return new ReviewRepairWorkflow(preparation,new ReviewIsolationExecutor(mongo,archive,selected,journal),1,1000,System::nanoTime); + } + ReviewRepairPreparation.Prepared prepare() { + return preparation.prepare(new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW),()->true); + } + Document project(){return direct.getDatabase(database).getCollection("projects").withReadConcern(ReadConcern.MAJORITY).find().first();} + Document operation(){return direct.getDatabase(database).getCollection(ReviewRepairJournal.COLLECTION).withReadConcern(ReadConcern.MAJORITY).find().first();} + void resume(){stallCommand.set(null);proxy.paused.set(false);proxy.release.countDown();mongo.getDb().withTimeout(3000,TimeUnit.MILLISECONDS).runCommand(new Document("ping",1));} + + @Test void actualLostCommitReplyKeepsCommittedReceiptAndNeverRepeatsVersionWrite()throws Exception { + var prepared=prepare();stallCommand.set("commitTransaction");var workflow=workflow(reader); + try(var worker=Executors.newSingleThreadExecutor()) { + var running=worker.submit(()->workflow.isolate(prepared,"actor",()->true)); + try { + assertTrue(commandEntered.await(5,TimeUnit.SECONDS));assertTrue(proxy.withheld.await(5,TimeUnit.SECONDS));assertFalse(running.isDone()); + assertEquals("APPLIED",operation().get("state"));var committed=project(); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1),workflow.status()); + assertEquals("UNKNOWN",running.get(15,TimeUnit.SECONDS).state());assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status()); + assertEquals("APPLIED",operation().get("state"));assertEquals(committed,project()); + resume();try(var restarted=workflow(reader)) { + var recovered=restarted.isolate(prepared,"actor",()->true);assertEquals("APPLIED",recovered.state()); + assertEquals(operation().get("afterSha256"),recovered.afterSha256());assertEquals(committed,project()); + } + } finally {proxy.close();workflow.close();} + } + } + + @Test void lostAbortReplyCannotTurnPermissionLossIntoAppliedRepair()throws Exception { + var prepared=prepare();var before=project();var permitted=new AtomicBoolean(true);var selected=spy(reader); + doAnswer(i->{var captured=(RawReviewSnapshotReader.Captured)i.callRealMethod();if(!captured.sha256().equals(prepared.sha256()))permitted.set(false);return captured;}) + .when(selected).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + var workflow=workflow(selected);stallCommand.set("abortTransaction"); + try(var worker=Executors.newSingleThreadExecutor()) { + var running=worker.submit(()->workflow.isolate(prepared,"actor",permitted::get)); + try { + assertTrue(commandEntered.await(5,TimeUnit.SECONDS));assertTrue(proxy.withheld.await(5,TimeUnit.SECONDS));assertFalse(running.isDone()); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1),workflow.status()); + var failure=assertThrows(ExecutionException.class,()->running.get(15,TimeUnit.SECONDS));assertInstanceOf(SecurityException.class,failure.getCause()); + assertEquals(before,project());assertEquals("EXECUTING",operation().get("state"));assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status()); + resume();try(var restarted=workflow(reader)) {assertEquals("UNKNOWN",restarted.isolate(prepared,"actor",()->true).state());assertEquals(before,project());} + } finally {proxy.close();workflow.close();} + } + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java new file mode 100644 index 000000000..effbbab20 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java @@ -0,0 +1,139 @@ +package net.modtale.service.admin.review; + +import org.junit.jupiter.api.Test; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairWorkflowTest { + final ReviewRepairPreparation preparation = mock(ReviewRepairPreparation.class); + final ReviewIsolationExecutor isolation = mock(ReviewIsolationExecutor.class); + final ReviewRepairPreparation.Prepared prepared = new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(), "a".repeat(64), 1000, 2000); + final ReviewRepairPreparation.Request request = new ReviewRepairPreparation.Request(prepared.id(), "p", 0, "v", prepared.sha256(), "actor", ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + + @Test void stoppedOrUnauthorizedRequestsNeverReachStorage() { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + assertThrows(SecurityException.class, () -> workflow.isolate(prepared, "actor", () -> false)); + workflow.close(); + assertThrows(IllegalStateException.class, () -> workflow.prepare(request, () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0), workflow.status()); + verifyNoInteractions(preparation, isolation); + } + + @Test void preparationAndIsolationShareAdmissionWithoutAWaitQueue() throws Exception { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + var entered = new CountDownLatch(1);var release = new CountDownLatch(1); + when(preparation.prepare(eq(request), any())).thenAnswer(i -> { + entered.countDown();assertTrue(release.await(5, TimeUnit.SECONDS)); + assertTrue(i.getArgument(1).getAsBoolean());return prepared; + }); + try (var workers = Executors.newSingleThreadExecutor()) { + var running = workers.submit(() -> workflow.prepare(request, () -> true)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("OPEN", 1), workflow.status()); + verifyNoInteractions(isolation); + workflow.close(); + assertEquals(new ReviewRepairWorkflow.Status("DRAINING", 1), workflow.status()); + } finally { release.countDown(); } + assertInstanceOf(IllegalStateException.class, assertThrows(ExecutionException.class, () -> running.get(5, TimeUnit.SECONDS)).getCause()); + } + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0), workflow.status()); + } + + @Test void twoSlotsCanRunButAThirdCannotEnter() throws Exception { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 2); + var entered = new CountDownLatch(2);var release = new CountDownLatch(1); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + entered.countDown();assertTrue(release.await(5, TimeUnit.SECONDS));return new ReviewIsolationExecutor.Result("UNKNOWN", null); + }); + try (var workers = Executors.newFixedThreadPool(2)) { + var first = workers.submit(() -> workflow.isolate(prepared, "actor", () -> true)); + var second = workers.submit(() -> workflow.isolate(prepared, "actor", () -> true)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> workflow.prepare(request, () -> true)); + assertEquals(2, workflow.status().active()); + } finally { release.countDown(); } + first.get(5, TimeUnit.SECONDS);second.get(5, TimeUnit.SECONDS); + } + assertEquals(0, workflow.status().active()); + } + + @Test void deadlineIsMonotonicAndExpiredWorkReleasesAdmission() { + var ticks = new AtomicLong(Long.MAX_VALUE - 500000000L); + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1, 1000, ticks::get); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + assertTrue(i.getArgument(2).getAsBoolean()); + ticks.addAndGet(1000000000L); + i.getArgument(2).getAsBoolean();return null; + }); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("OPEN", 0), workflow.status()); + } + + @Test void permissionCheckCannotConsumeDeadlineAndThenAuthorizeWork() { + var ticks = new AtomicLong();var calls = new AtomicLong(); + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1, 1000, ticks::get); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + i.getArgument(2).getAsBoolean();return null; + }); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> { + if (calls.incrementAndGet() == 2) ticks.addAndGet(1000000000L);return true; + })); + assertEquals(0, workflow.status().active()); + } + + @Test void interruptionBeforeAdmissionDoesNotClearInterruptOrTouchStorage() throws Exception { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + try (var worker = Executors.newSingleThreadExecutor()) { + worker.submit(() -> { + Thread.currentThread().interrupt(); + try { assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true));assertTrue(Thread.currentThread().isInterrupted()); } + finally { Thread.interrupted(); } + }).get(5, TimeUnit.SECONDS); + } + verifyNoInteractions(preparation, isolation); + } + + @Test void committedReceiptIsNotDiscardedByConcurrentShutdown() { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + var committed = new ReviewIsolationExecutor.Result("APPLIED", "b".repeat(64)); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { workflow.close();return committed; }); + assertEquals(committed, workflow.isolate(prepared, "actor", () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0), workflow.status()); + } + + @Test void exceptionsReleaseSlotsAndLimitsAreValidated() { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenThrow(new IllegalStateException("storage unavailable")); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + verify(isolation, times(2)).execute(eq(prepared), eq("actor"), any());assertEquals(0, workflow.status().active()); + assertThrows(IllegalArgumentException.class, () -> new ReviewRepairWorkflow(preparation, isolation, 3)); + assertThrows(IllegalArgumentException.class, () -> new ReviewRepairWorkflow(preparation, isolation, 1, 999, System::nanoTime)); + } + @Test void overdueWorkRemainsCountedUntilItActuallyReturns() throws Exception { + var ticks = new AtomicLong();var entered = new CountDownLatch(1);var release = new CountDownLatch(1); + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1, 1000, ticks::get); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + entered.countDown();assertTrue(release.await(5, TimeUnit.SECONDS));return new ReviewIsolationExecutor.Result("UNKNOWN", null); + }); + try (var worker = Executors.newSingleThreadExecutor()) { + var running = worker.submit(() -> workflow.isolate(prepared, "actor", () -> true)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS));ticks.set(1000000000L); + assertEquals(new ReviewRepairWorkflow.Status("OPEN", 1, 1), workflow.status()); + assertThrows(IllegalStateException.class, () -> workflow.prepare(request, () -> true)); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING", 1, 1), workflow.status()); + } finally { release.countDown(); } + running.get(5, TimeUnit.SECONDS); + } + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0, 0), workflow.status()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java new file mode 100644 index 000000000..969cf186e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java @@ -0,0 +1,104 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import com.mongodb.*; +import org.bson.Document; +import org.bson.types.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewSnapshotArchiveTest { + MongoClient client;MongoTemplate mongo;String database;ReviewSnapshotArchive archive; + byte[] key=new byte[32]; + @BeforeEach void setup() { + Arrays.fill(key,(byte)19);String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");database="warden_snapshot_"+UUID.randomUUID().toString().replace("-","");mongo=new MongoTemplate(client,database); + archive=new ReviewSnapshotArchive(mongo,"one",Map.of("one",key)); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewSnapshotArchive.Snapshot snapshot(Object project,int size) { + byte[] bytes=new byte[size];new Random(17).nextBytes(bytes); + return new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),project,3,"operator",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,1000,2000,bytes); + } + @Test void boundedChunksRetainExactOpaqueBytesAndBsonIdentityAcrossRestart() { + String id="abcdefabcdefabcdefabcdef"; + for(Object project:List.of(id,new ObjectId(id))) { + var source=snapshot(project,ReviewSnapshotArchive.CHUNK_BYTES+17);var retained=archive.retain(source); + assertArrayEquals(source.versionBytes(),retained.versionBytes());assertEquals(project,retained.projectId()); + var restarted=new ReviewSnapshotArchive(mongo,"one",Map.of("one",key));assertArrayEquals(source.versionBytes(),restarted.load(source.id()).versionBytes()); + assertArrayEquals(source.versionBytes(),restarted.retain(source).versionBytes()); + } + assertEquals(2,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(4,mongo.getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + } + @ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(ints={5,262144,16777216}) + void byteBoundariesRoundTripWithoutTruncation(int size) { + var source=snapshot("project",size);assertArrayEquals(source.versionBytes(),archive.retain(source).versionBytes()); + assertEquals((size+ReviewSnapshotArchive.CHUNK_BYTES-1)/ReviewSnapshotArchive.CHUNK_BYTES,mongo.getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + } + @Test void originalBsonIncludingUnknownFieldsAndMalformedTypedStateIsPreserved() { + var original=new Document("_id","v").append("scanResult","malformed typed state").append("unknown",new Document("date",new Date(123)) + .append("binary",new Binary((byte)0x80,new byte[]{1,2,3})).append("decimal",new Decimal128(new java.math.BigDecimal("123.4500")))); + var encoded=new org.bson.RawBsonDocument(original,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[encoded.remaining()];encoded.get(bytes); + var source=new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),new ObjectId(),0,"operator",ReviewSnapshotArchive.Action.REPLACE_REVIEW,1000,2000,bytes); + var restored=archive.retain(source);assertArrayEquals(bytes,restored.versionBytes()); + var decoded=new org.bson.RawBsonDocument(restored.versionBytes()).decode(new org.bson.codecs.DocumentCodec());assertEquals(original,decoded); + } + @ParameterizedTest @ValueSource(strings={"actorId","projectId","versionIndex","action","expiresAt","sha256","keyId","extra"}) + void changedMetadataCannotAuthenticate(String field) { + var source=snapshot("project",17);archive.retain(source); + Object value=switch(field){case "versionIndex"->4;case "expiresAt"->3000L;case "action"->"REPLACE_REVIEW";case "projectId"->new ObjectId();default->"changed";}; + mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",source.id()),new Document("$set",new Document(field,value))); + assertThrows(IllegalStateException.class,()->archive.load(source.id())); + } + @ParameterizedTest @ValueSource(strings={"missing","changed","extra","wrongType"}) + void missingOrAlteredChunksCannotRestoreSnapshot(String mutation) { + var source=snapshot("project",ReviewSnapshotArchive.CHUNK_BYTES+7);archive.retain(source);var chunks=mongo.getCollection(ReviewSnapshotArchive.CHUNKS); + var query=new Document("_id",source.id()+":0"); + switch(mutation) { + case "missing"->chunks.deleteOne(query); + case "changed"->{byte[] bytes=new byte[ReviewSnapshotArchive.CHUNK_BYTES];chunks.updateOne(query,new Document("$set",new Document("payload",new Binary(bytes))));} + case "extra"->chunks.updateOne(query,new Document("$set",new Document("extra",1))); + default->chunks.updateOne(query,new Document("$set",new Document("payload","not bytes"))); + } + assertThrows(IllegalStateException.class,()->archive.load(source.id())); + } + @Test void reusedIdentityCannotReplaceOriginalSnapshot() { + var source=snapshot("project",17);archive.retain(source); + var different=new ReviewSnapshotArchive.Snapshot(source.id(),"other",source.versionIndex(),source.actorId(),source.action(),source.createdAt(),source.expiresAt(),source.versionBytes()); + assertThrows(IllegalStateException.class,()->archive.retain(different));assertEquals("project",archive.load(source.id()).projectId()); + } + @Test void rotationRetainsReadAccessOnlyWithOriginalVerificationKey() { + var source=snapshot("project",17);archive.retain(source);byte[] newer=new byte[32];Arrays.fill(newer,(byte)23); + var rotated=new ReviewSnapshotArchive(mongo,"two",Map.of("one",key,"two",newer));assertArrayEquals(source.versionBytes(),rotated.load(source.id()).versionBytes()); + assertArrayEquals(source.versionBytes(),rotated.retain(source).versionBytes()); + assertThrows(IllegalStateException.class,()->new ReviewSnapshotArchive(mongo,"two",Map.of("two",newer)).load(source.id())); + assertThrows(IllegalStateException.class,()->new ReviewSnapshotArchive(mongo,"one",Map.of("one",newer)).load(source.id())); + } + @ParameterizedTest @ValueSource(strings={ReviewSnapshotArchive.METADATA,ReviewSnapshotArchive.CHUNKS}) + void committedInsertWithLostAcknowledgementRecoversWithoutReplacingBytes(String collection) { + var configured=mongo.getCollection(collection).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + var intercepted=spy(configured);var template=spy(mongo);doReturn(intercepted).when(template).getCollection(collection); + doReturn(intercepted).when(intercepted).withReadPreference(any());doReturn(intercepted).when(intercepted).withReadConcern(any());doReturn(intercepted).when(intercepted).withWriteConcern(any()); + doAnswer(i->{i.callRealMethod();throw new MongoException("Lost acknowledgement");}).when(intercepted).insertOne(any(Document.class)); + var source=snapshot("project",ReviewSnapshotArchive.CHUNK_BYTES+3);var result=new ReviewSnapshotArchive(template,"one",Map.of("one",key)).retain(source); + assertArrayEquals(source.versionBytes(),result.versionBytes());assertEquals(1,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(2,mongo.getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + } + @Test void conflictingPartialUploadNeverCreatesAuthenticatedMetadata() { + var source=snapshot("project",17);mongo.getCollection(ReviewSnapshotArchive.CHUNKS).insertOne(new Document("_id",source.id()+":0").append("payload",new Binary(new byte[17]))); + assertThrows(IllegalStateException.class,()->archive.retain(source));assertEquals(0,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertThrows(IllegalStateException.class,()->archive.load(source.id())); + } + @Test void limitsAndDefensiveCopiesPreventMutableSnapshotOrKeyInputs() { + assertThrows(IllegalArgumentException.class,()->snapshot("project",ReviewSnapshotArchive.MAX_BYTES+1)); + assertThrows(IllegalArgumentException.class,()->new ReviewSnapshotArchive(mongo,"missing",Map.of("one",key))); + assertThrows(IllegalArgumentException.class,()->new ReviewSnapshotArchive(mongo,"one",Map.of("one",new byte[3]))); + var source=snapshot("project",17);var before=source.versionBytes();source.versionBytes()[0]++;assertArrayEquals(before,source.versionBytes()); + Arrays.fill(key,(byte)0);archive.retain(source);assertArrayEquals(before,archive.load(source.id()).versionBytes());assertFalse(source.toString().contains("operator")); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java new file mode 100644 index 000000000..fc2893196 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java @@ -0,0 +1,28 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewStateDiagnosticCursorTest { + @Test void exactBsonIdentityAndPhysicalPositionRoundTrip() { + for(Object id:List.of("a",new ObjectId("abcdefabcdefabcdefabcdef"),"abcdefabcdefabcdefabcdef","\n$position","😀".repeat(128))) + for(boolean after:List.of(false,true)) { + var cursor=new RemoteReviewDiscovery.Cursor(id,after?0:16777216,after); + assertEquals(cursor,ReviewStateDiagnosticCursor.decode(ReviewStateDiagnosticCursor.encode(cursor))); + } + assertNull(ReviewStateDiagnosticCursor.decode(null));assertNull(ReviewStateDiagnosticCursor.encode(null)); + } + @Test void rejectsOtherScopesAndNoncanonicalOrUnboundedTokens() { + for(String token:List.of("","1.s.0.YQ","d1.v.2.SECURITY.s.0.YQ","d2.v.1.s.0.YQ","d1.a.1.s.1.YQ","d1.v.1.s.00.YQ", + "d1.v.1.s.0.YQ=","d1.v.1.s.0.YR","d1.v.1.s.0._w","d1.v.1.s.16777217.YQ","d1.v.1.s.0.","d1.v.1.o.0.YQ","d1.v.1.s.0.YQ.extra","x".repeat(811))) + assertThrows(IllegalArgumentException.class,()->ReviewStateDiagnosticCursor.decode(token),token); + assertThrows(IllegalArgumentException.class,()->ReviewStateDiagnosticCursor.encode(new RemoteReviewDiscovery.Cursor("\ud800",0,false))); + } + @Test void diagnosticScopeCannotBeUsedAsOrdinaryQueueCursor() { + var token=ReviewStateDiagnosticCursor.encode(new RemoteReviewDiscovery.Cursor("a",0,false)); + assertThrows(IllegalArgumentException.class,()->ModerationQueueCursor.decode(token)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java new file mode 100644 index 000000000..50c68e087 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java @@ -0,0 +1,136 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static net.modtale.service.admin.review.ReviewStateDiagnosticReader.Reason.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewStateDiagnosticReaderTest { + MongoClient client;MongoDatabase db;MongoCollection projects;ReviewStateDiagnosticReader reader; + String request=UUID.randomUUID().toString(); + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_diagnostic_"+UUID.randomUUID().toString().replace("-","")); + projects=db.getCollection("projects");reader=new ReviewStateDiagnosticReader(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document version(String id) {return new Document("_id",id).append("reviewStatus","PENDING").append("fileUrl","file.zip").append("hash","a".repeat(64)) + .append("scanResult",new Document("status","SCANNING").append("scanState","QUEUED").append("scanAttempt",1).append("scanRequestId",request));} + Document binding(String project,String id) {return new Document("projectId",project).append("versionId",id).append("requestId",request).append("attempt",1) + .append("filePath","file.zip").append("artifactSha256","a".repeat(64)).append("contextSha256","b".repeat(64)).append("policyVersion","warden-3.0.0:"+"c".repeat(64)) + .append("reviewConfigSha256","d".repeat(64)).append("manualRescan",false);} + Document scan(Document version){return version.get("scanResult",Document.class);} + void insert(Object id,List versions){projects.insertOne(new Document("_id",id).append("versions",versions));} + @Test void validWaitingRecordsAreNotDiagnosedAsBroken() { + var queued=version("q");var remote=version("r");scan(remote).put("scanState","REMOTE_REVIEW");scan(remote).put("remoteReview",binding("a","r")); + scan(remote).put("remotePoll",new Document("token",UUID.randomUUID().toString()).append("leaseUntil",new Date(Long.MAX_VALUE)).append("nextPollAt",new Date(Long.MAX_VALUE))); + insert("a",List.of(queued,remote));var before=projects.find().first();var page=reader.page(null,64); + assertEquals(2,page.examined());assertTrue(page.items().isEmpty());assertNotNull(page.next());assertNull(reader.page(page.next(),64).next());assertEquals(before,projects.find().first()); + } + @ParameterizedTest @ValueSource(strings={"missing","false"}) + void legacyDefaultManualModeAgreesWithTypedPersistence(String representation) { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");var b=binding("a","v"); + if(!representation.equals("missing"))scan(v).put("manualRescan",false); + scan(v).put("remoteReview",b);insert("a",List.of(v)); + var mongo=new MongoTemplate(client,db.getName()); + var typed=mongo.findById("a",net.modtale.model.project.Project.class).getVersions().getFirst(); + assertFalse(typed.getScanResult().isManualRescan());assertFalse(typed.getScanResult().getRemoteReview().manualRescan()); + assertTrue(reader.page(null,1).items().isEmpty()); + } + @Test void explicitNullManualModeIsNotSilentlyTreatedAsDefault() { + var v=version("v");scan(v).put("manualRescan",null);insert("a",List.of(v)); + assertThrows(RuntimeException.class,()->new MongoTemplate(client,db.getName()).findById("a",net.modtale.model.project.Project.class)); + assertEquals(List.of(INVALID_MANUAL_MODE),reader.page(null,1).items().getFirst().reasons()); + } + @ParameterizedTest @ValueSource(strings={"null","scalar","array","missingKey","badToken","extraKey","badDate"}) + void invalidPollsSkippedByDiscoveryRemainDiagnosable(String mutation) { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");scan(v).put("remoteReview",binding("a","v")); + var poll=new Document("token",null).append("leaseUntil",new Date()).append("nextPollAt",new Date());Object stored=poll; + switch(mutation) { + case "null" -> stored=null;case "scalar" -> stored="unexpected";case "array" -> stored=List.of(poll); + case "missingKey" -> poll.remove("token");case "badToken" -> poll.put("token","not-a-uuid"); + case "extraKey" -> poll.put("extra",1);case "badDate" -> poll.put("leaseUntil",0); + } + scan(v).put("remotePoll",stored);insert("a",List.of(v)); + assertEquals(List.of(INVALID_POLL),reader.page(null,1).items().getFirst().reasons()); + } + @ParameterizedTest @ValueSource(strings={"scalar","job","attempt","manual","missingField","longPath","missingManual","nullManual"}) + void bindingCorruptionDoesNotRequireTypedDeserialization(String mutation) { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");var binding=binding("a","v");Object stored=binding; + switch(mutation) { + case "scalar" -> stored=42;case "job" -> binding.put("jobId",List.of("unexpected")); + case "attempt" -> binding.put("attempt",1.5);case "manual" -> binding.put("manualRescan","false"); + case "missingManual" -> binding.remove("manualRescan");case "nullManual" -> binding.put("manualRescan",null); + case "missingField" -> binding.remove("contextSha256");case "longPath" -> binding.put("filePath","p".repeat(100000)); + } + scan(v).put("remoteReview",stored);insert("a",List.of(v));assertEquals(List.of(INVALID_BINDING),reader.page(null,1).items().getFirst().reasons()); + if(mutation.equals("missingManual") || mutation.equals("nullManual"))assertThrows(RuntimeException.class,()->new MongoTemplate(client,db.getName()).findById("a",net.modtale.model.project.Project.class)); + } + @Test void missingBindingAndMismatchedIdentityAreDistinct() { + var missing=version("missing");scan(missing).put("scanState","REMOTE_REVIEW"); + var mismatch=version("mismatch");scan(mismatch).put("scanState","REMOTE_REVIEW");scan(mismatch).put("remoteReview",binding("other","mismatch")); + insert("a",List.of(missing,mismatch));var page=reader.page(null,64); + assertEquals(List.of(MISSING_BINDING),page.items().getFirst().reasons());assertEquals(List.of(BINDING_MISMATCH),page.items().get(1).reasons()); + } + @Test void badRequestArtifactAndManualModeAreAllReportedWithoutEchoingContent() { + var v=version("v").append("hash",List.of("bad")).append("fileUrl","secret".repeat(10000)); + scan(v).put("scanRequestId","secret".repeat(10000));scan(v).put("scanAttempt",Long.MAX_VALUE);scan(v).put("manualRescan","invalid");scan(v).put("scanState","unexpected"); + scan(v).put("issues",List.of(new Document("secret","do not expose")));insert("a",List.of(v)); + var item=reader.page(null,1).items().getFirst();assertEquals(List.of(INVALID_REQUEST,INVALID_ARTIFACT,INVALID_MANUAL_MODE,INVALID_SCAN_STATE),item.reasons()); + assertFalse(item.toString().contains("secret"));assertFalse(item.toString().contains("do not expose")); + } + @Test void duplicateAndMalformedVersionIdsKeepPhysicalPositions() { + insert("a",List.of(version("same"),version("same"),version("x".repeat(10000)),version("bad\n"))); + var page=reader.page(null,64);assertEquals(4,page.items().size()); + for(int i=0;i<4;i++)assertEquals(i,page.items().get(i).versionIndex()); + assertEquals(List.of(DUPLICATE_VERSION_ID),page.items().getFirst().reasons());assertNull(page.items().get(2).versionId());assertNull(page.items().get(3).versionId()); + } + @Test void boundedPhysicalPagingCrossesHealthySlotsAndMixedBsonIds() { + String hex="abcdefabcdefabcdefabcdef";var versions=new ArrayList(); + for(int i=0;i<70;i++)versions.add(i%3==0?"malformed sibling":version("v"+i)); + var invalid=version("last");scan(invalid).put("remotePoll",null);versions.add(invalid); + insert(hex,versions);var invalid2=version("other");scan(invalid2).put("scanRequestId","bad");insert(new ObjectId(hex),List.of(invalid2)); + net.modtale.service.security.scan.RemoteReviewDiscovery.Cursor cursor=null;var found=new ArrayList();int examined=0,pages=0; + do {var page=reader.page(cursor,7);assertTrue(page.examined()<=7);found.addAll(page.items());examined+=page.examined();cursor=page.next();assertTrue(++pages<20);}while(cursor!=null); + assertEquals(72,examined);assertEquals(2,found.size());assertEquals(hex,found.getFirst().projectId());assertEquals(70,found.getFirst().versionIndex()); + assertEquals(new ObjectId(hex),found.get(1).projectId());assertEquals(List.of(POLL_WITHOUT_REMOTE_STATE,INVALID_POLL),found.getFirst().reasons()); + } + @Test void deletedRootContinuesAndNonpendingRecordsAreNotDiagnosed() { + var v=version("v");scan(v).put("scanRequestId","bad");insert("a",List.of(v,v));insert("b",List.of(v)); + var page=reader.page(null,1);projects.deleteOne(new Document("_id","a"));assertEquals("b",reader.page(page.next(),1).items().getFirst().projectId()); + projects.deleteMany(new Document());v.put("reviewStatus","APPROVED");insert("a",List.of(v));assertEquals(0,reader.page(null,1).examined()); + } + @Test void httpPaginationTraversesEmptyDiagnosticPageAndMixedIdentityRoots()throws Exception { + String hex="abcdefabcdefabcdefabcdef";var bad=version("bad");scan(bad).put("remotePoll",null); + insert(hex,List.of(version("healthy"),bad));insert(new ObjectId(hex),List.of(bad)); + var controller=new net.modtale.controller.admin.ReviewStateDiagnosticController(reader); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper();String cursor=null;var types=new ArrayList();int calls=0,examined=0; + do { + var request=org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/diagnostics/page").param("limit","1"); + if(cursor!=null)request.param("cursor",cursor); + var response=mvc.perform(request).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")).andReturn().getResponse(); + var body=mapper.readTree(response.getContentAsString());examined+=body.get("examinedSlots").asInt(); + if(calls==0) {assertTrue(body.get("items").isEmpty());assertFalse(body.get("nextCursor").isNull());} + for(var item:body.get("items")) {types.add(item.get("position").get("projectIdType").asText());assertEquals(hex,item.get("position").get("projectId").asText());} + cursor=body.get("nextCursor").isNull()?null:body.get("nextCursor").asText();assertTrue(++calls<=4); + } while(cursor!=null); + assertEquals(4,calls);assertEquals(3,examined);assertEquals(List.of("STRING","OBJECT_ID"),types); + } + @Test void invalidLimitsFailBeforeDatabaseAccess(){assertThrows(IllegalArgumentException.class,()->reader.page(null,0));assertThrows(IllegalArgumentException.class,()->reader.page(null,65));} + @Test void malformedOriginIsDiagnosedWithoutExposingItsPayload() { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");scan(v).put("remoteReview",binding("a","v").append("origin",new Document("deploymentId","private".repeat(100000)).append("callerScope",List.of("bad")))); + insert("a",List.of(v));var before=projects.find().first();var page=reader.page(null,1); + assertTrue(page.items().getFirst().reasons().contains(INVALID_BINDING));assertFalse(page.toString().contains("private"));assertEquals(before,projects.find().first()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java new file mode 100644 index 000000000..db29bbe9f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java @@ -0,0 +1,504 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import net.modtale.config.db.MongoConfig; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import org.springframework.data.mongodb.core.convert.*; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class VersionReviewPersistenceIntegrationTest { + private net.modtale.model.project.RemoteReviewBinding prepareRemote() { + var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);scan.setScanState("SCANNING");scan.setScanAttempt(1); + scan.setScanRequestId(UUID.randomUUID().toString());version.setScanResult(scan);version.setFileUrl("original.zip"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0",version),Project.class); + return new RemoteReviewBinding(id,version.getId(),scan.getScanRequestId(),1,version.getFileUrl(),version.getHash(), + net.modtale.service.security.scan.ArtifactReviewContext.automaticallyReviewableFingerprint(version), + "warden-3.0.0:"+"a".repeat(64),"b".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + } + @Test void remoteBindingSurvivesRestartAndAttachesOnlyOneJob() { + var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + assertTrue(remote.bind(version,binding));assertTrue(remote.bind(version,binding)); + var restored=mongo.findById(id,Project.class).getVersions().getFirst();assertEquals(binding,restored.getScanResult().getRemoteReview()); + var restarted=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);String job=UUID.randomUUID().toString(); + assertTrue(restarted.attachJob(restored,binding,job));assertTrue(restarted.attachJob(restored,binding,job)); + assertFalse(restarted.attachJob(restored,binding,UUID.randomUUID().toString()));assertFalse(restarted.bind(restored,binding)); + assertEquals(binding.withJobId(job),mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemoteReview()); + } + @Test void changedContextCannotBindOrAttachRemoteJob() { + var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion","changed"),Project.class); + assertFalse(remote.bind(version,binding)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion",version.getManifestVersion()),Project.class); + assertTrue(remote.bind(version,binding)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.hash","c".repeat(64)),Project.class); + assertFalse(remote.attachJob(version,binding,UUID.randomUUID().toString())); + } + @Test void newRequestAndCompetingConfigurationCannotReplaceBinding() { + var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + assertTrue(remote.bind(version,binding)); + var other=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(), + binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),"c".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + assertFalse(remote.bind(version,other)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.scanRequestId",UUID.randomUUID().toString()),Project.class); + assertFalse(remote.attachJob(version,binding,UUID.randomUUID().toString()));assertFalse(remote.bind(version,binding)); + } + @Test void legacyRecoveryCannotReplaceRemoteReviewEvenWithOldSnapshot() { + var binding=prepareRemote();var observed=version.getScanResult();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + assertTrue(remote.bind(version,binding)); + var scans=new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class),org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + assertFalse(scans.queueRetryAttempt(id,version.getId(),1,new ScanResult(),observed,1)); + assertFalse(scans.updateTimedOutScan(id,version.getId(),new ScanResult(),1,observed,1)); + var restored=mongo.findById(id,Project.class).getVersions().getFirst(); + assertFalse(scans.queueRetryAttempt(id,version.getId(),1,new ScanResult(),restored.getScanResult(),1)); + assertEquals(binding,restored.getScanResult().getRemoteReview()); + } + private net.modtale.service.security.scan.RemoteReviewPollStore pollStore() { + return new net.modtale.service.security.scan.RemoteReviewPollStore(mongo); + } + private RemoteReviewBinding boundRemote() { + var binding=prepareRemote();assertTrue(new net.modtale.service.security.scan.RemoteReviewPersistence(mongo).bind(version,binding));return binding; + } + @Test void onlyOneConcurrentPollerOwnsTheRetainedRequest()throws Exception { + var binding=boundRemote();var start=new java.util.concurrent.CountDownLatch(1); + try(var executor=java.util.concurrent.Executors.newFixedThreadPool(2)) { + var a=executor.submit(()->{start.await();return pollStore().claim(binding,30000);}); + var b=executor.submit(()->{start.await();return pollStore().claim(binding,30000);});start.countDown(); + var first=a.get();var second=b.get();assertTrue((first==null)!=(second==null)); + assertTrue(pollStore().isCurrent(first==null?second:first));assertNull(pollStore().claim(binding,30000)); + } + } + @Test void pollLeaseTakeoverFencesExpiredOwnerAndPreservesUnrelatedFields() { + var binding=boundRemote();mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.unmodeledField","keep").set("versions.1.rejectionReason","sibling").set("title","keep title"),Project.class); + var old=pollStore().claim(binding,30000);assertNotNull(old); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.remotePoll.leaseUntil",new Date(0)),Project.class); + assertFalse(pollStore().isCurrent(old));assertFalse(pollStore().release(old,100));assertNull(pollStore().attachJob(old,UUID.randomUUID().toString())); + var next=pollStore().claim(binding,30000);assertNotNull(next);assertNotEquals(old.token(),next.token());assertFalse(pollStore().release(old,100)); + assertTrue(pollStore().isCurrent(next));var project=mongo.findById(id,Project.class);assertEquals("keep title",project.getTitle());assertEquals("sibling",project.getVersions().get(1).getRejectionReason()); + assertNotNull(project.getVersions().getFirst().getScanResult().getRemotePoll()); + assertEquals("keep",mongo.getCollection("projects").find().first().getList("versions",Document.class).getFirst().getString("unmodeledField")); + } + @Test void completedPollPersistsDatabaseDelayAndCannotBeReused() { + var binding=boundRemote();var before=client.getDatabase(database).runCommand(new Document("hello",1)).getDate("localTime"); + var owner=pollStore().claim(binding,30000);assertNotNull(owner); + var after=client.getDatabase(database).runCommand(new Document("hello",1)).getDate("localTime"); + var acquired=mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemotePoll(); + assertTrue(acquired.leaseUntil().getTime()>=before.getTime()+30000);assertTrue(acquired.leaseUntil().getTime()<=after.getTime()+30000); + assertTrue(pollStore().release(owner,30000));assertFalse(pollStore().release(owner,100));assertFalse(pollStore().isCurrent(owner));assertNull(pollStore().claim(binding,30000)); + var state=mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemotePoll();assertNull(state.token());assertEquals(new Date(0),state.leaseUntil()); + assertTrue(state.nextPollAt().getTime()>System.currentTimeMillis()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.remotePoll.nextPollAt",new Date(0)),Project.class); + assertNotNull(pollStore().claim(binding,30000)); + } + @Test void onlyLivePollCanAttachJobAndMustUseUpdatedBindingAfterward() { + var binding=boundRemote();var owner=pollStore().claim(binding,30000);String job=UUID.randomUUID().toString(); + assertFalse(new net.modtale.service.security.scan.RemoteReviewPersistence(mongo).attachJob(version,binding,job)); + var attached=pollStore().attachJob(owner,job);assertNotNull(attached);assertEquals(binding.withJobId(job),attached.binding()); + assertFalse(pollStore().isCurrent(owner));assertTrue(pollStore().isCurrent(attached));assertNotNull(pollStore().attachJob(attached,job)); + assertThrows(IllegalArgumentException.class,()->pollStore().attachJob(attached,UUID.randomUUID().toString()));assertTrue(pollStore().release(attached,100)); + } + @Test void contextAndRequestChangesInvalidatePollOwnership() { + var binding=boundRemote();var owner=pollStore().claim(binding,30000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion","new runtime"),Project.class); + assertFalse(pollStore().isCurrent(owner));assertFalse(pollStore().release(owner,100));assertNull(pollStore().claim(binding,30000)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion",version.getManifestVersion()) + .set("versions.0.scanResult.scanRequestId",UUID.randomUUID().toString()),Project.class); + assertFalse(pollStore().isCurrent(owner));assertNull(pollStore().attachJob(owner,UUID.randomUUID().toString())); + } + @Test void malformedPollAndDuplicateVersionIdentityCannotBeClaimed() { + var binding=boundRemote();mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.scanResult.remotePoll",new Document("leaseUntil","bad")),Project.class);assertNull(pollStore().claim(binding,30000)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().unset("versions.0.scanResult.remotePoll") + .set("versions.1._id",version.getId()),Project.class);assertNull(pollStore().claim(binding,30000)); + } + @Test void pollIntervalsAreBoundedBeforeDatabaseWork() { + var binding=boundRemote();assertThrows(IllegalArgumentException.class,()->pollStore().claim(binding,999));assertThrows(IllegalArgumentException.class,()->pollStore().claim(binding,120001)); + var owner=pollStore().claim(binding,30000);assertThrows(IllegalArgumentException.class,()->pollStore().release(owner,99));assertThrows(IllegalArgumentException.class,()->pollStore().release(owner,3600001)); + } + @Test void staleRecoveryCannotReplaceAnAttemptThatStartedAfterItsSnapshot() { + var scan = new ScanResult(); scan.setStatus(ScanStatus.SCANNING); scan.setScanState("QUEUED"); + scan.setScanAttempt(1); scan.setScanTimestamp(System.currentTimeMillis()-120_000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.PENDING).set("versions.0.scanResult", scan), Project.class); + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + assertTrue(scans.markAttemptRunning(id,"version-a",1)); + var queued = new ScanResult(); queued.setStatus(ScanStatus.SCANNING); queued.setScanState("QUEUED"); queued.setScanAttempt(2); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,scan,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",new ScanResult(),1,scan,60_000)); + assertEquals("SCANNING",mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanState()); + } + @Test void recoveryRequiresDatabaseExpiryAndAllowsOnlyOneStaleRetry() { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var observed = new ScanResult(); observed.setStatus(ScanStatus.SCANNING); observed.setScanState("SCANNING"); + observed.setScanAttempt(1); observed.setScanTimestamp(System.currentTimeMillis()+86_400_000); + var query = Query.query(Criteria.where("_id").is(id)); + mongo.updateFirst(query,new Update().set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING) + .set("versions.0.scanResult",observed).set("futureField","preserve"),Project.class); + var queued = new ScanResult(); queued.setStatus(ScanStatus.SCANNING); queued.setScanState("QUEUED"); queued.setScanAttempt(2); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",new ScanResult(),1,observed,60_000)); + observed.setScanTimestamp(System.currentTimeMillis()-120_000); + mongo.updateFirst(query,new Update().set("versions.0.scanResult",observed),Project.class); + // Same state/attempt but a newer timestamp must reject the stale recovery snapshot. + mongo.updateFirst(query,new Update().set("versions.0.scanResult.scanTimestamp",System.currentTimeMillis()),Project.class); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + mongo.updateFirst(query,new Update().set("versions.0.scanResult",observed),Project.class); + assertTrue(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + assertEquals("preserve",mongo.getCollection("projects").find().first().getString("futureField")); + assertEquals(2,mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanAttempt()); + } + @Test void expiredTerminalRecoveryIsConditionalAndPreservesOtherVersions() { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var observed=new ScanResult(); observed.setStatus(ScanStatus.SCANNING); observed.setScanState("WAITING_RETRY"); + observed.setScanAttempt(3); observed.setScanTimestamp(0); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING).set("versions.0.scanResult",observed),Project.class); + var failed=new ScanResult(); failed.setStatus(ScanStatus.FAILED); failed.setScanState("FAILED"); + assertTrue(scans.updateTimedOutScan(id,"version-a",failed,3,observed,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",failed,3,observed,60_000)); + assertEquals("version-b",mongo.findById(id,Project.class).getVersions().get(1).getId()); + } + @Test void oldAttemptOneCannotFailAReplacementAttemptOne() throws Exception { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var current = new ScanResult(); current.setStatus(ScanStatus.SCANNING); current.setScanState("SCANNING"); current.setScanAttempt(1); current.setScanRequestId("new-request"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING).set("versions.0.scanResult",current),Project.class); + var failed = new ScanResult(); failed.setStatus(ScanStatus.FAILED); + assertFalse(scans.updateFailedScan(id,"version-a",failed,1)); + assertEquals(ScanStatus.SCANNING,mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getStatus()); + assertFalse(scans.updateFailedScan(id,"version-a",failed,1,"old-request")); + // Reproduce the pre-fix predicate directly: it only distinguishes attempt/state, so it matches the replacement. + var legacy = scans.getClass().getDeclaredMethod("buildVersionAttemptQuery",String.class,String.class,int.class,String[].class); + legacy.setAccessible(true); + var query = (Query)legacy.invoke(scans,id,"version-a",1,new String[]{"SCANNING","QUEUED","WAITING_RETRY",null}); + assertEquals(1,mongo.updateFirst(query,new Update().set("versions.$.scanResult",failed),Project.class).getModifiedCount()); + + } + @Test void requestIdentityBindsClaimsOutcomesAndRecoveryEvenWhenAttemptAndTimestampMatch() { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var current = new ScanResult(); current.setStatus(ScanStatus.SCANNING); current.setScanState("QUEUED"); + current.setScanAttempt(1); current.setScanRequestId("replacement"); current.setScanTimestamp(System.currentTimeMillis()-120_000); + var query=Query.query(Criteria.where("_id").is(id)); + mongo.updateFirst(query,new Update().set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING) + .set("versions.0.scanResult",current),Project.class); + assertEquals("replacement",mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanRequestId()); + assertFalse(scans.markAttemptRunning(id,"version-a",1)); + assertFalse(scans.markAttemptRunning(id,"version-a",1,"obsolete")); + current.setScanRequestId("obsolete"); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,new ScanResult(),current,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",new ScanResult(),1,current,60_000)); + assertTrue(scans.markAttemptRunning(id,"version-a",1,"replacement")); + assertFalse(scans.markAttemptRunning(id,"version-a",1,"replacement")); + mongo.updateFirst(query,new Update().set("versions.0.hash","b".repeat(64)),Project.class); + var reviewed=mongo.findById(id,Project.class).getVersions().getFirst(); + var result=ScanEvidenceFixtures.complete(false); + var routing=new net.modtale.service.security.scan.ScanRoutingService.RoutingDecision( + net.modtale.service.security.scan.ScanRoutingService.RoutingAction.REQUIRE_REVIEW,0); + assertFalse(scans.applyScanOutcome(id,"version-a",1,result,routing,reviewed)); + assertFalse(scans.applyScanOutcome(id,"version-a",1,result,routing,reviewed,"obsolete")); + assertTrue(scans.applyScanOutcome(id,"version-a",1,result,routing,reviewed,"replacement")); + assertEquals("replacement",mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanRequestId()); + } + @Test void metadataRepairPreservesVersionsEvidenceAndUnknownFields() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("futureField", "retain"), Project.class); + var repairs = new ProjectReviewPersistence(mongo); + var project = mongo.findById(id, Project.class); + var snapshot = repairs.capture(id, ProjectReviewSnapshot.token(project)); + var originalVersions = snapshot.raw().get("versions"); + assertTrue(repairs.applyMetadataRepair(snapshot, Map.of("title", "Metadata repaired", "links", Map.of("source", "https://example.com")))); + var raw = mongo.getCollection("projects").find().first(); + assertEquals(originalVersions, raw.get("versions")); + assertEquals("retain", raw.getString("futureField")); + assertEquals("Metadata repaired", raw.getString("title")); + assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + } + @Test void metadataRepairRejectsForgedStateAndConcurrentDecisions() { + var repairs = new ProjectReviewPersistence(mongo); + var project = mongo.findById(id, Project.class); + var snapshot = repairs.capture(id, ProjectReviewSnapshot.token(project)); + assertThrows(ResponseStatusException.class, () -> repairs.applyMetadataRepair(snapshot, Map.of("status", "PUBLISHED"))); + assertEquals("original", mongo.findById(id, Project.class).getTitle()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.REJECTED), Project.class); + assertFalse(repairs.applyMetadataRepair(snapshot, Map.of("title", "Stale title"))); + assertEquals("original", mongo.findById(id, Project.class).getTitle()); + assertEquals(ProjectVersion.ReviewStatus.REJECTED, mongo.findById(id, Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void conditionalApprovalRetainsFindingEvidenceAfterScanPruningAndReload() { + var loaded = mongo.findById(id, Project.class); + var selected = loaded.getVersions().getFirst(); + selected.setHash(selected.getScanResult().getSecurityEvidence().artifactSha256()); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); + issue.setType("Network"); issue.setDescription("connect"); issue.setSeverity("LOW"); + selected.getScanResult().setIssues(new ArrayList<>(List.of(issue))); + mongo.save(loaded); + selected = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.capture(id, selected.getId(), VersionReviewSnapshot.token(selected)); + var classification = new net.modtale.service.security.issue.SecurityIssueClassificationService( + new net.modtale.config.properties.AppSecurityProperties("test", 60, 120, 2, 12, 15, 120, 25, 2)); + new net.modtale.service.security.issue.SecurityIssueApprovalService(classification) + .markIssuesAcceptedForApprovedVersion(selected); + selected.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertTrue(persistence.apply(snapshot, selected)); + var restored = mongo.findById(id, Project.class); + var baseline = restored.getVersions().getFirst().getApprovedIssueBaselines().getFirst(); + assertTrue(baseline.getEvidenceIdentity().matches("ie1:[0-9a-f]{64}")); + assertNull(restored.getVersions().getFirst().getScanResult()); + var nextScan = ScanEvidenceFixtures.complete(false); + var nextIssue = new ScanResult.ScanIssue(); nextIssue.setFilePath("Mod.class"); + nextIssue.setType("Network"); nextIssue.setDescription("connect"); nextIssue.setSeverity("LOW"); + nextScan.setIssues(new ArrayList<>(List.of(nextIssue))); + classification.annotateAgainstBaselines(nextScan, classification.collectApprovedIssueBaselines(restored, null)); + assertTrue(nextIssue.isHistoricalFileEvidenceIdentical()); + assertFalse(nextIssue.isResolved()); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.cleared(nextScan)); + } + @Test void duplicateVersionIdentityIsRejectedBeforeSnapshotCapture() { + var collection=mongo.getCollection("projects");var raw=collection.find().first();var duplicate=raw.getList("versions",Document.class).getFirst(); + collection.updateOne(new Document("_id",raw.get("_id")),new Document("$push",new Document("versions",duplicate))); + assertThrows(ResponseStatusException.class,()->persistence.captureForRescan(id,version.getId(),VersionReviewSnapshot.rescanToken(version))); + assertThrows(ResponseStatusException.class,()->persistence.capture(id,version.getId(),VersionReviewSnapshot.token(version))); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"rescan","decision","finding"}) + void duplicateInsertedAfterSnapshotPreventsEveryVersionDecisionWrite(String action) { + var snapshot=persistence.captureForRescan(id,version.getId(),VersionReviewSnapshot.rescanToken(version)); + var duplicate=new Document(snapshot.version());duplicate.put("versionNumber","concurrent duplicate"); + var collection=mongo.getCollection("projects");collection.updateOne(new Document("_id",snapshot.projectId()),new Document("$push",new Document("versions",duplicate))); + var before=collection.find().first();boolean applied; + switch(action) { + case "rescan" -> applied=persistence.queueRescan(snapshot,queued()); + case "finding" -> applied=persistence.appendFindingReview(snapshot,"new decision"); + default -> {version.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED);applied=persistence.apply(snapshot,version);} + } + assertFalse(applied);assertEquals(before,collection.find().first()); + } + @Test void unrelatedMalformedSiblingDoesNotPreventCapturingUniqueVersion() { + var collection=mongo.getCollection("projects");var raw=collection.find().first(); + collection.updateOne(new Document("_id",raw.get("_id")),new Document("$push",new Document("versions",new Document("$each",List.of( + new Document("_id","broken").append("scanResult","invalid"))).append("$position",0)))); + var snapshot=persistence.captureForRescan(id,version.getId(),VersionReviewSnapshot.rescanToken(version)); + assertTrue(persistence.queueRescan(snapshot,queued())); + } + @Test void caseInsensitiveCollectionCannotHideAChangedSnapshot() { + var collection=mongo.getCollection("projects");var original=collection.find().first();collection.drop(); + mongo.getDb().createCollection("projects",new com.mongodb.client.model.CreateCollectionOptions().collation( + com.mongodb.client.model.Collation.builder().locale("en").collationStrength(com.mongodb.client.model.CollationStrength.SECONDARY).build())); + collection=mongo.getCollection("projects");collection.insertOne(original); + collection.updateOne(new Document("_id",original.get("_id")),new Document("$set",new Document("versions.0.versionNumber","release"))); + var current=mongo.findById(id,Project.class).getVersions().getFirst(); + var snapshot=persistence.captureForRescan(id,current.getId(),VersionReviewSnapshot.rescanToken(current)); + collection.updateOne(new Document("_id",original.get("_id")),new Document("$set",new Document("versions.0.versionNumber","RELEASE"))); + assertFalse(persistence.queueRescan(snapshot,queued())); + } + private ScanResult queued() { + var scan = new ScanResult(); scan.setStatus(ScanStatus.SCANNING); + scan.setScanState("QUEUED"); scan.setScanAttempt(2); return scan; + } + @Test void missingPriorManifestDoesNotPreventRescanning() { + mongo.getCollection(net.modtale.config.db.MongoArtifactManifestStore.COLLECTION).deleteMany(new Document()); + var current = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.captureForRescan(id, current.getId(), VersionReviewSnapshot.rescanToken(current)); + assertTrue(persistence.queueRescan(snapshot, queued())); + var result = mongo.findById(id, Project.class).getVersions().getFirst(); + assertEquals(ProjectVersion.ReviewStatus.PENDING, result.getReviewStatus()); + assertEquals("QUEUED", result.getScanResult().getScanState()); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.cleared(result.getScanResult())); + } + @Test void rescanPreservesConcurrentSiblingAndUnknownFieldsAndOnlyOneRequestWins() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.legacyMarker", "retain"), Project.class); + var snapshot = persistence.captureForRescan(id, version.getId(), VersionReviewSnapshot.rescanToken(version)); + var competing = persistence.captureForRescan(id, version.getId(), VersionReviewSnapshot.rescanToken(version)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("title", "concurrent title").set("versions.1.reviewStatus", ProjectVersion.ReviewStatus.REJECTED), Project.class); + assertTrue(persistence.queueRescan(snapshot, queued())); + assertFalse(persistence.queueRescan(competing, queued())); + var saved = mongo.findById(id, Project.class); + assertEquals("concurrent title", saved.getTitle()); + assertEquals(ProjectVersion.ReviewStatus.REJECTED, saved.getVersions().get(1).getReviewStatus()); + assertEquals("QUEUED", saved.getVersions().getFirst().getScanResult().getScanState()); + assertEquals(2, saved.getVersions().getFirst().getScanResult().getScanAttempt()); + assertEquals("retain", mongo.getCollection("projects").find().first().getList("versions", Document.class).getFirst().getString("legacyMarker")); + } + @Test void rescanRejectsChangesToArtifactContextOrReviewDecision() { + var changes = List.of(new Update().set("versions.0.hash", "f".repeat(64)), + new Update().set("versions.0.gameVersions", List.of("changed")), + new Update().set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.APPROVED), + new Update().set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.REJECTED)); + for (var change : changes) { + var current = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.captureForRescan(id, current.getId(), VersionReviewSnapshot.rescanToken(current)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), change, Project.class); + assertFalse(persistence.queueRescan(snapshot, queued())); + assertThrows(ResponseStatusException.class, () -> persistence.captureForRescan(id, current.getId(), VersionReviewSnapshot.rescanToken(current))); + assertNotEquals("QUEUED", mongo.findById(id, Project.class).getVersions().getFirst().getScanResult().getScanState()); + } + } + private MongoClient client; + private MongoTemplate mongo; + private VersionReviewPersistence persistence; + private String database; + private ProjectVersion version; + private final String id="abcdefabcdefabcdefabcdef"; + @BeforeEach void setup() throws Exception { + database="warden_review_test_"+UUID.randomUUID().toString().replace("-",""); + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27029"); + if(!Set.of("27029","27030").contains(port)) throw new IllegalArgumentException("Unexpected test database port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + var factory=new SimpleMongoClientDatabaseFactory(client,database); + var conversions=new MongoConfig().mongoCustomConversions(new net.modtale.config.db.MongoArtifactManifestStore(factory)); + var context=new MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new MappingMongoConverter(new DefaultDbRefResolver(factory),context); + converter.setCustomConversions(conversions);converter.afterPropertiesSet(); + mongo=new MongoTemplate(factory,converter);persistence=new VersionReviewPersistence(mongo); + var project=new Project();project.setId(id);project.setTitle("original"); + version=new ProjectVersion();version.setId("version-a");version.setVersionNumber("1.0"); + version.setHash("a".repeat(64));version.setScanResult(ScanEvidenceFixtures.complete(true)); + var sibling=new ProjectVersion();sibling.setId("version-b");sibling.setVersionNumber("2.0"); + project.setVersions(List.of(version,sibling));mongo.insert(project); + } + @Test void projectPublicationPreservesUnreviewedVersionsAndUnknownFields() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.legacyMarker", "preserve").set("unmodeledProjectField", "preserve"), Project.class); + var project = mongo.findById(id, Project.class); + var projectPersistence = new ProjectReviewPersistence(mongo); + var snapshot = projectPersistence.capture(id, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PUBLISHED); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertTrue(projectPersistence.apply(snapshot, "version-a")); + var saved = mongo.findById(id, Project.class); + assertEquals(ProjectStatus.PUBLISHED, saved.getStatus()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, saved.getVersions().getFirst().getReviewStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, saved.getVersions().get(1).getReviewStatus()); + var raw = mongo.getCollection("projects").find().first(); + assertEquals("preserve", raw.getString("unmodeledProjectField")); + assertEquals("preserve", raw.getList("versions", Document.class).getFirst().getString("legacyMarker")); + } + @Test void concurrentProjectChangePreventsPublication() { + var project = mongo.findById(id, Project.class); + var projectPersistence = new ProjectReviewPersistence(mongo); + var snapshot = projectPersistence.capture(id, ProjectReviewSnapshot.token(project)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("title", "changed"), Project.class); + snapshot.project().setStatus(ProjectStatus.PUBLISHED); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertFalse(projectPersistence.apply(snapshot, "version-a")); + assertEquals("changed", mongo.findById(id, Project.class).getTitle()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, mongo.findById(id, Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void changedVersionInvalidatesProjectReviewInBrowser() { + var project = mongo.findById(id, Project.class); + String token = ProjectReviewSnapshot.token(project); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.hash", "f".repeat(64)), Project.class); + assertThrows(ResponseStatusException.class, () -> new ProjectReviewPersistence(mongo).capture(id, token)); + } + @Test void referencedManifestsAreVerifiedAndMissingOrCorruptRecordsCannotClear() { + var raw = mongo.getCollection("projects").find().first(); + var evidence = raw.getList("versions", Document.class).getFirst().get("scanResult", Document.class).get("securityEvidence", Document.class); + String reference = evidence.getString("manifestRef"); + assertNotNull(reference); assertFalse(evidence.containsKey("entryHashes")); + assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + var manifests = mongo.getCollection(net.modtale.config.db.MongoArtifactManifestStore.COLLECTION); + manifests.updateOne(new Document("_id", reference), new Document("$set", new Document("entries", List.of(new Document("path", "substituted").append("sha256", "f".repeat(64)))))); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + manifests.deleteOne(new Document("_id", reference)); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + } + @Test void legacyEmbeddedEvidenceMigratesWithoutChangingTheBrowserReviewToken() { + var scan = ScanEvidenceFixtures.complete(true); + var hashes = new LinkedHashMap(); hashes.put("z/nested.class", "b".repeat(64)); hashes.put("a.$manifest", "c".repeat(64)); + var evidence = scan.getSecurityEvidence(); + var updated = new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), SecurityManifest.identity(hashes), true, true, "COMPLETED", hashes); + var legacy = new net.modtale.config.db.SecurityEvidenceConverters.Write().convert(updated); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.scanResult.securityEvidence", legacy), Project.class); + var before = mongo.findById(id, Project.class); + assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(before.getVersions().getFirst().getScanResult())); + String token = VersionReviewSnapshot.token(before.getVersions().getFirst()); + mongo.save(before); + var after = mongo.findById(id, Project.class); + assertEquals(token, VersionReviewSnapshot.token(after.getVersions().getFirst())); + assertEquals(hashes, after.getVersions().getFirst().getScanResult().getSecurityEvidence().entryHashes()); + var raw = mongo.getCollection("projects").find().first().getList("versions", Document.class).getFirst() + .get("scanResult", Document.class).get("securityEvidence", Document.class); + assertTrue(raw.containsKey("manifestRef")); assertFalse(raw.containsKey("entryHashes")); + } + @Test void sixLargeManifestsNoLongerExceedTheProjectDocumentLimit() { + var hashes = new LinkedHashMap(); + for (int i = 0; i < 20_000; i++) hashes.put(String.format("p/%05d/", i) + "a".repeat(30) + ".class", "a".repeat(64)); + String identity = SecurityManifest.identity(hashes); + var project = new Project(); project.setId("large-manifest-fixture"); + var versions = new ArrayList(); + for (int i = 0; i < 6; i++) { + var item = new ProjectVersion(); item.setId("v" + i); + var scan = ScanEvidenceFixtures.complete(true); + var prior = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(prior.policyVersion(), prior.artifactSha256(), identity, true, true, "COMPLETED", hashes)); + item.setScanResult(scan); versions.add(item); + } + project.setVersions(versions); mongo.insert(project); + var raw = mongo.getCollection("projects").find(new Document("_id", "large-manifest-fixture")).first(); + assertNotNull(raw); + assertTrue(new org.bson.RawBsonDocument(raw, new org.bson.codecs.DocumentCodec()).getByteBuffer().remaining() < 64_000); + assertEquals(1, mongo.getCollection(net.modtale.config.db.MongoArtifactManifestStore.COLLECTION).countDocuments(new Document("_id", identity))); + var restored = mongo.findById("large-manifest-fixture", Project.class); + for (var item : restored.getVersions()) assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(item.getScanResult())); + } + @AfterEach void cleanup() {if(client!=null) {client.getDatabase(database).drop();client.close();}} + @Test void conditionalApprovalPreservesConcurrentSiblingAndProjectChanges() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.legacyMarker","preserve"),Project.class); + var snapshot=persistence.capture(id,version.getId(),VersionReviewSnapshot.token(version)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("title","concurrent title") + .set("versions.1.rejectionReason","independent sibling edit"),Project.class); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);version.setScanResult(null); + assertTrue(persistence.apply(snapshot,version)); + var result=mongo.findById(id,Project.class); + assertEquals("concurrent title",result.getTitle()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED,result.getVersions().getFirst().getReviewStatus()); + assertEquals("independent sibling edit",result.getVersions().get(1).getRejectionReason()); + var raw=mongo.getCollection("projects").find().first(); + assertEquals("preserve",raw.getList("versions",Document.class).getFirst().getString("legacyMarker")); + } + @Test void artifactReplacementDuringDecisionPreventsApproval() { + var snapshot=persistence.capture(id,version.getId(),VersionReviewSnapshot.token(version)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.hash","b".repeat(64)),Project.class); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertFalse(persistence.apply(snapshot,version)); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(id,Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void staleBrowserEvidenceIsRejectedBeforeCapturingDecision() { + String token=VersionReviewSnapshot.token(version); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.scanTimestamp",1234L),Project.class); + assertEquals(409,assertThrows(ResponseStatusException.class,()->persistence.capture(id,version.getId(),token)).getStatusCode().value()); + } + @Test void malformedRemoteReferenceCannotBeSilentlyDiscardedByManualRescan() { + var collection=mongo.getCollection("projects");var raw=collection.find().first(); + collection.updateOne(new Document("_id",raw.get("_id")),new Document("$set",new Document("versions.0.scanResult.remoteReview","malformed retained reference"))); + var before=collection.find().first();var stored=before.getList("versions",Document.class).getFirst(); + var snapshot=new VersionReviewPersistence.Snapshot(before.get("_id"),stored); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->persistence.queueRescan(snapshot,queued())); + assertEquals(before,collection.find().first()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java new file mode 100644 index 000000000..4f0856e1a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java @@ -0,0 +1,18 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class VersionReviewSnapshotTest { + @Test void tokenTracksArtifactMetadataAndScanButIgnoresDownloadCounters() { + var version=new ProjectVersion();version.setId("version");version.setHash("a".repeat(64)); + version.setScanResult(ScanEvidenceFixtures.complete(true)); + String original=VersionReviewSnapshot.token(version); + version.setDownloadCount(50);assertEquals(original,VersionReviewSnapshot.token(version)); + version.setOverrideFileUrl("other.zip");assertNotEquals(original,VersionReviewSnapshot.token(version)); + version.setOverrideFileUrl(null);version.getScanResult().setScanAttempt(2);assertNotEquals(original,VersionReviewSnapshot.token(version)); + version.getScanResult().setScanAttempt(0);version.setHash("b".repeat(64));assertNotEquals(original,VersionReviewSnapshot.token(version)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/VersionReviewTransitionTest.java b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewTransitionTest.java new file mode 100644 index 000000000..91730e5cd --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewTransitionTest.java @@ -0,0 +1,36 @@ +package net.modtale.service.admin.review; + +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static net.modtale.service.admin.review.VersionReviewTransition.Change.*; + +class VersionReviewTransitionTest { + Document original(){return new Document("_id","v").append("gameVersions",List.of("a","b")).append("scanResult",new Document("remoteReview","malformed-but-retained"));} + VersionReviewTransition.Transition compare(Document before,Document after){return VersionReviewTransition.classify(List.of(before),List.of(after)).getFirst();} + @ParameterizedTest @ValueSource(strings={"changelog","channel","incompatibleProjectIds","downloadCount"}) + void metadataDoesNotRequestAnotherReview(String field){var old=original();var next=new Document(old).append(field,"changed");var result=compare(old,next);assertEquals(Set.of(METADATA),result.changes());assertFalse(result.requiresRetention());} + @Test void runtimeOrderAloneDoesNotInvalidateReview(){var old=original();var next=new Document(old).append("gameVersions",List.of("b","a"));assertTrue(compare(old,next).changes().isEmpty());assertNotEquals(compare(old,next).beforeSha256(),compare(old,next).afterSha256());} + @Test void runtimeMultiplicityAndMalformedTypesAreNotNormalizedAway(){var old=original();assertEquals(Set.of(CONTEXT),compare(old,new Document(old).append("gameVersions",List.of("a","a","b"))).changes());assertEquals(Set.of(CONTEXT),compare(old,new Document(old).append("gameVersions",List.of(1))).changes());} + @Test void contextAndQueueReplacementAreBothRetained(){var old=original();var next=new Document(old).append("dependencies",List.of(new Document("projectId","dep"))).append("scanResult",new Document("scanState","QUEUED"));var result=compare(old,next);assertEquals(Set.of(CONTEXT,REVIEW),result.changes());assertTrue(result.requiresRetention());} + @Test void prunedRemoteHistoryAndSecurityHoldsStillRequireRetention(){for(String field:List.of("retainedRemoteReview","reviewReplacement","reviewIsolation","replacementSecurityHold","approvedSecurityEvidence","findingReviewHead")){var old=new Document("_id","v").append(field,"malformed");assertTrue(compare(old,new Document(old).append("hash","new")).requiresRetention(),field);}} + @Test void unknownChangesAndNumericTypeChangesAreConservative(){var old=original().append("futureSecurityField",1);var result=compare(old,new Document(old).append("futureSecurityField",1L));assertEquals(Set.of(UNKNOWN),result.changes());assertTrue(result.requiresRetention());} + @Test void reorderingMatchesVersionIdsAndDoesNotCreateReviews(){var a=original();var b=new Document("_id","b");var results=VersionReviewTransition.classify(List.of(a,b),List.of(b,a));assertEquals(1,results.getFirst().afterIndex());assertTrue(results.stream().allMatch(r->r.changes().isEmpty()));} + @Test void removalsAndNewUploadsHaveSeparateIdentities(){var results=VersionReviewTransition.classify(List.of(original()),List.of(new Document("_id","new").append("versionNumber","same-label")));assertEquals(Set.of(REMOVED),results.getFirst().changes());assertTrue(results.getFirst().requiresRetention());assertEquals(Set.of(ADDED),results.get(1).changes());assertFalse(results.get(1).priorHistory());} + @Test void draftWithoutHistoryDoesNotRequireRetention(){var old=new Document("_id","v").append("reviewStatus","PENDING").append("securityApprovedAt",0L);assertFalse(compare(old,new Document(old).append("scanResult",new Document("scanState","QUEUED"))).requiresRetention());} + @Test void duplicateAndMalformedIdsAreRejected(){var old=original();assertThrows(IllegalArgumentException.class,()->VersionReviewTransition.classify(List.of(old,old),List.of()));assertThrows(IllegalArgumentException.class,()->VersionReviewTransition.classify(List.of(),List.of(new Document("_id",1))));} + @Test void returnedClassificationCannotBeMutatedWithItsInput(){var old=original();var next=new Document(old).append("hash","new");var result=compare(old,next);var hash=result.afterSha256();next.put("hash","other");assertEquals(hash,result.afterSha256());assertThrows(UnsupportedOperationException.class,()->result.changes().clear());} + @Test void missingApprovalEvidenceIsNotTreatedAsANewDraft(){var old=new Document("_id","v").append("reviewStatus","APPROVED");assertTrue(compare(old,new Document(old).append("scanResult",new Document("scanState","QUEUED"))).requiresRetention());} + @Test void unknownNullFieldRemovalCannotEscapeClassification(){var old=new Document("_id","v").append("unknown",null);var result=compare(old,new Document("_id","v"));assertEquals(Set.of(UNKNOWN),result.changes());assertTrue(result.requiresRetention());} + + @Test void dependencyOrderIsIgnoredButDuplicatesAndRawTypesRemainSignificant(){ + var a=new Document("projectId","a").append("versionNumber","1");var b=new Document("projectId","b").append("versionNumber","2"); + var old=original().append("dependencies",List.of(a,b));assertTrue(compare(old,new Document(old).append("dependencies",List.of(b,a))).changes().isEmpty()); + assertEquals(Set.of(CONTEXT),compare(old,new Document(old).append("dependencies",List.of(a,a,b))).changes()); + assertEquals(Set.of(CONTEXT),compare(old,new Document(old).append("dependencies",List.of(new Document(a).append("versionNumber",1),b))).changes()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java index d28bdc015..31fe3f4dd 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java @@ -38,6 +38,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.anyString; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -46,6 +48,7 @@ class LifecycleServiceTest { private LifecycleService lifecycleService; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private ValidationService validationService; @@ -84,6 +87,12 @@ void setUp() { mock(VersionDependencyService.class), mock(ProjectDeletionService.class) ); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.apply(any(), any())).thenReturn(true); + when(reviewPersistence.submitDraft(any())).thenReturn(true); ProjectDraftWorkflowService projectDraftWorkflowService = new ProjectDraftWorkflowService( projectRepository, projectService, @@ -94,10 +103,10 @@ void setUp() { projectAccessService, projectMutationGuard, versionMutationOrchestrationService, - new AppLimitProperties(10, 5, 10, 5, 5, 5, 20, 10) + new AppLimitProperties(10, 5, 10, 5, 5, 5, 20, 10), + reviewPersistence ); ProjectPublicationService projectPublicationService = new ProjectPublicationService( - projectRepository, projectService, projectNotificationService, webhookService, @@ -105,7 +114,8 @@ void setUp() { mock(ScoringService.class), accessControlService, projectAccessService, - securityIssueAnalysisService + securityIssueAnalysisService, + reviewPersistence ); lifecycleService = new LifecycleService( projectDraftWorkflowService, @@ -125,7 +135,7 @@ void createDraftUsesOrganizationOwnerAndSanitizesSavedFields() { when(userRepository.findById("org-1")).thenReturn(java.util.Optional.of(organization)); when(sanitizationService.sanitizePlainText("Raw Title")).thenReturn("Clean Title"); when(sanitizationService.sanitizePlainText("Raw Description")).thenReturn("Clean Description"); - when(projectRepository.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(projectRepository.insert(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); Project draft = lifecycleService.createDraft( "Raw Title", @@ -154,7 +164,7 @@ void createDraftUsesOrganizationOwnerAndSanitizesSavedFields() { @Test void importedDetailsAreSavedTogetherInAnUnpublishedDraft() { User creator = user("user-1", "Creator", User.AccountType.USER, true); - when(projectRepository.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(projectRepository.insert(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); String source = "https://www.curseforge.com/hytale/mods/my-mod"; String icon = "https://media.forgecdn.net/avatars/1/icon.png"; @@ -167,7 +177,7 @@ void importedDetailsAreSavedTogetherInAnUnpublishedDraft() { assertEquals(source, draft.getLinks().get("CurseForge")); assertEquals(icon, draft.getImageUrl()); assertTrue(draft.getVersions().isEmpty()); - verify(projectRepository).save(draft); + verify(projectRepository).insert(draft); } @Test @@ -201,7 +211,7 @@ void submitProjectMarksVersionsPendingAndTriggersAdminWebhookWhenNoScanIsRunning verify(validationService).validateSlug("sky-tools"); verify(validationService).validateRepositoryUrl("https://github.com/modtale/sky-tools"); - verify(projectRepository).save(project); + verify(reviewPersistence).submitDraft(any()); verify(projectService).evictProjectCache(project); verify(webhookService).triggerAdminNewProjectWebhook(project); } @@ -221,16 +231,25 @@ void submitProjectQueuesInitialScanForDraftArtifactsBeforeReview() { project.setVersions(new ArrayList<>(List.of(uploadedDraftVersion))); ScanResult queuedScan = new ScanResult(ScanStatus.SCANNING, 0, List.of()); + queuedScan.setScanRequestId("submission-request"); when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_STATUS_SUBMIT")).thenReturn(true); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); + when(reviewPersistence.submitDraft(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> lifecycleService.submitProject("project-1", user)); + verify(scanService, never()).enqueueBackgroundScan(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyInt(), any()); + verify(webhookService, never()).triggerAdminNewProjectWebhook(any()); + project.setStatus(ProjectStatus.DRAFT); uploadedDraftVersion.setScanResult(null); + when(reviewPersistence.submitDraft(any())).thenReturn(true); + org.mockito.Mockito.clearInvocations(reviewPersistence, scanService); + lifecycleService.submitProject("project-1", user); assertEquals(ProjectStatus.PENDING, project.getStatus()); assertEquals(queuedScan, uploadedDraftVersion.getScanResult()); - verify(projectRepository).save(project); + verify(reviewPersistence).submitDraft(any()); verify(projectService).evictProjectCache(project); verify(scanService).enqueueBackgroundScan( "project-1", @@ -238,7 +257,7 @@ void submitProjectQueuesInitialScanForDraftArtifactsBeforeReview() { "/files/data/bundle.zip", "/files/data/bundle.zip", false, - 1 + 1, queuedScan.getScanRequestId() ); verify(webhookService, never()).triggerAdminNewProjectWebhook(project); } @@ -261,12 +280,12 @@ void submitProjectSkipsAdminWebhookWhileAnyVersionIsStillScanning() { lifecycleService.submitProject("project-1", user); - verify(projectRepository).save(project); + verify(reviewPersistence).submitDraft(any()); verify(webhookService, never()).triggerAdminNewProjectWebhook(project); } @Test - void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { + void publishProjectApprovesOnlySelectedVersionAndBroadcastsFirstPublication() { User admin = user("user-1", "ItsNeil17", User.AccountType.USER, true); Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PENDING); project.setCreatedAt(null); @@ -277,13 +296,18 @@ void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { scheduled.setScheduledPublishDate("2026-06-09T12:00:00"); ProjectVersion approved = version("1.1.0"); approved.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); - project.setVersions(new ArrayList<>(List.of(scheduled, approved))); + ProjectVersion uninspected = version("1.2.0"); + uninspected.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + project.setVersions(new ArrayList<>(List.of(scheduled, approved, uninspected))); when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.canApproveProjectReviews(admin)).thenReturn(true); when(projectRepository.save(project)).thenReturn(project); - lifecycleService.publishProject("project-1", admin); + var snapshot = new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), project); + when(reviewPersistence.capture("project-1", "review-token")).thenReturn(snapshot); + when(reviewPersistence.apply(snapshot, scheduled.getId())).thenReturn(true); + lifecycleService.publishProject("project-1", admin, "review-token", scheduled.getId()); assertEquals(ProjectStatus.PUBLISHED, project.getStatus()); assertNotNull(project.getCreatedAt()); @@ -294,7 +318,9 @@ void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { assertNull(scheduled.getScheduledPublishDate()); assertEquals(ProjectVersion.ReviewStatus.APPROVED, approved.getReviewStatus()); - verify(securityIssueAnalysisService).pruneApprovedScanResults(project); + assertEquals(ProjectVersion.ReviewStatus.PENDING, uninspected.getReviewStatus()); + verify(securityIssueAnalysisService).markIssuesAcceptedForApprovedVersion(scheduled); + verify(projectRepository, never()).save(project); verify(projectService).evictProjectCache(project); verify(projectNotificationService).notifyNewProject(project); verify(webhookService).triggerWebhook(project); @@ -302,6 +328,25 @@ void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { verify(trackingService).logNewProject("project-1"); } + @Test + void unboundOrConcurrentPublicationDoesNotBroadcastOrSave() { + User admin = user("admin", "Moderator", User.AccountType.USER, true); + Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PENDING); + ProjectVersion selected = version("1.0"); project.setVersions(List.of(selected)); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.canApproveProjectReviews(admin)).thenReturn(true); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> lifecycleService.publishProject("project-1", admin)); + var snapshot = new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), project); + when(reviewPersistence.capture("project-1", "token")).thenReturn(snapshot); + when(reviewPersistence.apply(snapshot, selected.getId())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> lifecycleService.publishProject("project-1", admin, "token", selected.getId())); + verify(projectRepository, never()).save(any()); + verify(projectNotificationService, never()).notifyNewProject(any()); + verify(webhookService, never()).triggerWebhook(any()); + } + @Test void publishProjectRestorationUsesProjectPermissionWithoutBroadcastingNewProjectEvents() { User maintainer = user("user-2", "ItsNeil17", User.AccountType.USER, true); diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java index 4f2690e8a..aa42a6c0b 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java @@ -27,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -36,6 +38,8 @@ class ProjectDeletionServiceTest { private ProjectDeletionService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; + private java.util.Map projects; private ProjectRepository projectRepository; private ProjectService projectService; private TrackingService trackingService; @@ -46,6 +50,12 @@ class ProjectDeletionServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + projects = new java.util.HashMap<>(); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), projects.get(invocation.getArgument(0)))); + when(reviewPersistence.applyDeletionState(any(), anyBoolean())).thenReturn(true); + when(reviewPersistence.deleteProject(any())).thenReturn(true); projectService = mock(ProjectService.class); trackingService = mock(TrackingService.class); scoringService = mock(ScoringService.class); @@ -58,7 +68,8 @@ void setUp() { trackingService, scoringService, projectArtifactDeletionService, - mongoTemplate + mongoTemplate, + reviewPersistence ); } @@ -70,7 +81,7 @@ void softDeleteMarksPublishedProjectsDeletedAndTracksRemoval() { assertEquals(ProjectStatus.DELETED, project.getStatus()); assertNotNull(project.getDeletedAt()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyDeletionState(any(), eq(false)); verify(projectService).evictProjectCache(project); verify(trackingService).logDeletedProject("project-1"); } @@ -93,7 +104,6 @@ void hardDeleteScrubsProjectsThatAreStillNeededForDependencies() { project.setDeletedAt(LocalDateTime.now()); when(projectRepository.findByDependency("project-1")).thenReturn(List.of(editableProject("dependent-1", ProjectClassification.DATA, ProjectStatus.PUBLISHED))); - when(projectRepository.save(project)).thenReturn(project); service.hardDelete(project); @@ -114,7 +124,7 @@ void hardDeleteScrubsProjectsThatAreStillNeededForDependencies() { verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/banner.png"), any()); verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/one.png"), any()); verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/two.png"), any()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyDeletionState(any(), eq(true)); verify(projectService).evictProjectCache(project); verify(projectRepository, never()).delete(project); verify(trackingService, never()).deleteProjectAnalytics("project-1"); @@ -150,17 +160,43 @@ void hardDeleteRemovesArtifactsAnalyticsAndProjectRecordsWhenNoDependentsRemain( verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/banner.png"), any()); verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/one.png"), any()); verify(mongoTemplate, times(2)).updateMulti(any(Query.class), any(Update.class), eq(net.modtale.model.user.User.class)); - verify(projectRepository).delete(project); + verify(reviewPersistence).deleteProject(argThat(snapshot -> "project-1".equals(snapshot.project().getId()))); verify(projectService).evictProjectCache(project); verify(trackingService).deleteProjectAnalytics("dep-1"); - verify(projectRepository).delete(orphan); + verify(reviewPersistence).deleteProject(argThat(snapshot -> "dep-1".equals(snapshot.project().getId()))); verify(projectService).evictProjectCache(orphan); } - private static Project editableProject(String id, ProjectClassification classification, ProjectStatus status) { + @Test void failedHardDeleteCannotTouchStorageAnalyticsOrUserRecords() { + var project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.DELETED); + project.setImageUrl("images/example.png"); + when(reviewPersistence.deleteProject(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.hardDelete(project)); + verifyNoInteractions(storageService, trackingService, mongoTemplate); + verify(projectService, never()).evictProjectCache(any()); + } + @Test void failedDependencyScrubCannotDeleteMediaOrEvictCurrentState() { + var project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.DELETED); + project.setImageUrl("images/example.png"); + when(projectRepository.findByDependency("project-1")).thenReturn(List.of(new Project())); + when(reviewPersistence.applyDeletionState(any(), eq(true))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.hardDelete(project)); + verifyNoInteractions(storageService, trackingService, mongoTemplate); + verify(projectService, never()).evictProjectCache(any()); + } + @Test void failedSoftDeleteDoesNotAnnounceRemoval() { + var project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PUBLISHED); + when(reviewPersistence.applyDeletionState(any(), eq(false))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.softDelete(project)); + verifyNoInteractions(storageService, trackingService); + verify(projectService, never()).evictProjectCache(any()); + } + + private Project editableProject(String id, ProjectClassification classification, ProjectStatus status) { Project project = new Project(); project.setId(id); + projects.put(id, project); project.setSlug("sky-tools"); project.setTitle("Sky Tools"); project.setClassification(classification); diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java new file mode 100644 index 000000000..e77badf5e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java @@ -0,0 +1,49 @@ +package net.modtale.service.project.lifecycle; + +import net.modtale.model.project.*; +import net.modtale.model.user.User; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.analytics.*; +import net.modtale.service.communication.*; +import net.modtale.service.project.access.ProjectAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.security.issue.SecurityIssueAnalysisService; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ProjectPublicationSecurityTest { + @Test void restoringVisibilityNeverApprovesPendingOrScheduledVersions() { + for (ProjectStatus status : List.of(ProjectStatus.ARCHIVED,ProjectStatus.UNLISTED,ProjectStatus.PRIVATE)) { + var repository=mock(ProjectRepository.class); + var access=mock(AccessControlService.class); + var projects=mock(ProjectAccessService.class); + var analysis=mock(SecurityIssueAnalysisService.class); + var persistence=mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + var service=new ProjectPublicationService(mock(ProjectService.class),mock(ProjectNotificationService.class), + mock(WebhookService.class),mock(TrackingService.class),mock(ScoringService.class),access,projects,analysis,persistence); + var project=new Project();project.setId("project");project.setStatus(status);project.setCreatedAt("2026-01-01T00:00:00"); + var pending=new ProjectVersion();pending.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + var scheduled=new ProjectVersion();scheduled.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); + scheduled.setScheduledPublishDate("2999-01-01T00:00:00"); + project.setVersions(List.of(pending,scheduled)); + var owner=new User(); + when(projects.requireProject("project")).thenReturn(project); + when(access.hasProjectPermission(project,owner,"PROJECT_STATUS_PUBLISH")).thenReturn(true); + when(repository.save(project)).thenReturn(project); + when(persistence.capture(eq("project"),anyString())).thenReturn( + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(),project)); + when(persistence.apply(any(),isNull())).thenReturn(true); + service.publishProject("project",owner); + verifyNoInteractions(repository); + assertEquals(ProjectStatus.PUBLISHED,project.getStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,pending.getReviewStatus(),status.name()); + assertEquals(ProjectVersion.ReviewStatus.SCHEDULED,scheduled.getReviewStatus(),status.name()); + assertEquals("2999-01-01T00:00:00",scheduled.getScheduledPublishDate()); + verifyNoInteractions(analysis); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java b/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java index 103a3ec34..7951c3c61 100644 --- a/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java @@ -1,8 +1,5 @@ package net.modtale.service.project.media; -import static org.mockito.Mockito.reset; -import static org.mockito.ArgumentMatchers.anyString; - import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -27,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,6 +33,7 @@ class ProjectMediaServiceTest { private ProjectMediaService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private AccessControlService accessControlService; @@ -46,6 +45,11 @@ class ProjectMediaServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyPresentation(any(), anyBoolean())).thenReturn(true); projectService = mock(ProjectService.class); accessControlService = mock(AccessControlService.class); projectAccessService = new ProjectAccessService(projectService, accessControlService); @@ -54,7 +58,7 @@ void setUp() { mediaUploadService = mock(MediaUploadService.class); service = new ProjectMediaService( - projectRepository, + reviewPersistence, projectService, projectAccessService, new ProjectMutationGuard(), @@ -95,13 +99,12 @@ void addGalleryVideoStoresNormalizedYouTubeWatchUrls() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); Project updated = service.addGalleryVideo("project-1", "https://youtu.be/dQw4w9WgXcQ?si=test", user); assertEquals(project, updated); assertEquals(List.of("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), project.getGalleryImages()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -152,13 +155,12 @@ void reorderGalleryPersistsTheRequestedOrder() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); Project updated = service.reorderGallery("project-1", List.of("c.png", "a.png", "b.png"), user); assertEquals(project, updated); assertEquals(List.of("c.png", "a.png", "b.png"), project.getGalleryImages()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -197,7 +199,7 @@ void removeGalleryImageDeletesTheStoredAssetAndEvictsProjectCache() { assertEquals(List.of("https://cdn.modtale.test/gallery/b.png"), project.getGalleryImages()); assertFalse(project.getGalleryImageCaptions().containsKey("https://cdn.modtale.test/gallery/a.png")); verify(projectDeletionService).deleteProjectMediaFile(project, "https://cdn.modtale.test/gallery/a.png"); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -211,14 +213,13 @@ void removeGalleryImageDoesNotDeleteYoutubeVideosFromStorage() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_REMOVE")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); service.removeGalleryImage("project-1", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", user); assertEquals(List.of(), project.getGalleryImages()); assertFalse(project.getGalleryImageCaptions().containsKey("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); verify(projectDeletionService, never()).deleteProjectMediaFile(any(), any()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -232,7 +233,6 @@ void updateGalleryImageCaptionStoresTrimmedCaptionsAndEvictsProjectCache() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); Project updated = service.updateGalleryImageCaption( "project-1", @@ -243,7 +243,7 @@ void updateGalleryImageCaptionStoresTrimmedCaptionsAndEvictsProjectCache() { assertEquals(project, updated); assertEquals("Opening shot", project.getGalleryImageCaptions().get("https://cdn.modtale.test/gallery/a.png")); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -257,12 +257,11 @@ void updateGalleryImageCaptionRemovesBlankCaptions() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); service.updateGalleryImageCaption("project-1", "https://cdn.modtale.test/gallery/a.png", " ", user); assertFalse(project.getGalleryImageCaptions().containsKey("https://cdn.modtale.test/gallery/a.png")); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -298,19 +297,19 @@ void updateProjectImageCleansUpExistingStoredAssetsThroughTheSharedDeletionHelpe when(accessControlService.hasProjectPermission(project, user, "PROJECT_EDIT_ICON")).thenReturn(true); when(mediaUploadService.uploadPublicUrl(eq(file), eq("project-media/project-1/images"), any())).thenReturn("https://cdn.modtale.test/images/new-icon.png"); - when(projectRepository.save(project)).thenThrow(new IllegalStateException("write failed")); - assertThrows(IllegalStateException.class, + when(reviewPersistence.applyPresentation(any(), eq(true))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.updateProjectImage("project-1", file, user, false)); verify(projectDeletionService, never()).deleteProjectMediaFile(any(), anyString()); project.setImageUrl("https://cdn.modtale.test/images/current-icon.png"); - reset(projectRepository); - when(projectRepository.save(project)).thenReturn(project); + clearInvocations(reviewPersistence); + when(reviewPersistence.applyPresentation(any(), eq(true))).thenReturn(true); service.updateProjectImage("project-1", file, user, false); assertEquals("https://cdn.modtale.test/images/new-icon.png", project.getImageUrl()); verify(projectDeletionService).deleteProjectMediaFile(project, "https://cdn.modtale.test/images/current-icon.png"); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -322,8 +321,8 @@ void galleryRemovalRequiresMembershipAndSuccessfulPersistenceBeforeDeletingStora when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_REMOVE")).thenReturn(true); assertThrows(InvalidProjectRequestException.class, () -> service.removeGalleryImage("project-1", "foreign.png", user)); - when(projectRepository.save(project)).thenThrow(new IllegalStateException("write failed")); - assertThrows(IllegalStateException.class, () -> service.removeGalleryImage("project-1", "owned.png", user)); + when(reviewPersistence.applyPresentation(any(), eq(true))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.removeGalleryImage("project-1", "owned.png", user)); verify(projectDeletionService, never()).deleteProjectMediaFile(any(), anyString()); } diff --git a/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java b/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java index b9ab6640c..00bc3e2aa 100644 --- a/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -28,6 +29,7 @@ class MetadataServiceTest { private MetadataService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private ValidationService validationService; @@ -38,6 +40,11 @@ class MetadataServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyPresentation(any(), anyBoolean())).thenReturn(true); projectService = mock(ProjectService.class); validationService = mock(ValidationService.class); accessControlService = mock(AccessControlService.class); @@ -46,6 +53,7 @@ void setUp() { service = new MetadataService( projectRepository, + reviewPersistence, projectService, validationService, projectAccessService, @@ -91,7 +99,7 @@ void updateMetadataAllowsMutableClassificationChangesAndSanitizesPrimaryFields() assertEquals("MIT", existing.getLicense()); assertFalse(existing.isCustomLicenseOpenSource()); verify(validationService).validateSlug("new-slug"); - verify(projectRepository).save(existing); + verify(reviewPersistence).applyPresentation(any(), eq(false)); verify(projectService).evictProjectCache(existing); } @@ -119,7 +127,7 @@ void updateMetadataPersistsOpenSourceFlagForCustomLicenses() { assertEquals("Sky Public License", existing.getLicense()); assertTrue(existing.isCustomLicenseOpenSource()); - verify(projectRepository).save(existing); + verify(reviewPersistence).applyPresentation(any(), eq(false)); } @Test @@ -164,7 +172,7 @@ void metadataCannotAttachAnArbitraryStoredFileAsTheProjectImage() { assertThrows(InvalidProjectRequestException.class, () -> service.updateMetadata("project-1", updated, user)); - verify(projectRepository, never()).save(existing); + verify(reviewPersistence, never()).applyPresentation(any(), anyBoolean()); verify(projectService, never()).evictProjectCache(existing); assertEquals("owned-image.png", existing.getImageUrl()); } @@ -184,7 +192,7 @@ void metadataMayRetainTheExistingProjectImage() { service.updateMetadata("project-1", updated, user); - verify(projectRepository).save(existing); + verify(reviewPersistence).applyPresentation(any(), eq(false)); assertEquals("owned-image.png", existing.getImageUrl()); } diff --git a/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java b/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java index 5331fce67..671d7b779 100644 --- a/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java @@ -33,7 +33,7 @@ class TeamServiceTest { private TeamService service; - private net.modtale.service.project.team.ProjectTeamPersistence reviewPersistence; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private UserRepository userRepository; private ProjectService projectService; @@ -46,9 +46,9 @@ class TeamServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); - reviewPersistence = mock(net.modtale.service.project.team.ProjectTeamPersistence.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> - new net.modtale.service.project.team.ProjectTeamPersistence.Snapshot(new org.bson.Document(), projectService.getRawProjectById(invocation.getArgument(0)))); + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), projectService.getRawProjectById(invocation.getArgument(0)))); when(reviewPersistence.applyTeam(any())).thenReturn(true); userRepository = mock(UserRepository.class); projectService = mock(ProjectService.class); diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java index 8b4adf566..e6b55ffcb 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java @@ -1,90 +1,115 @@ package net.modtale.service.project.version; -import java.util.ArrayList; -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import java.util.*; +import net.modtale.model.project.*; import net.modtale.service.communication.ProjectNotificationService; -import net.modtale.service.project.lifecycle.ScheduledReleaseExecutionService; -import net.modtale.service.project.lifecycle.ScheduledReleaseQueryService; +import net.modtale.service.project.lifecycle.*; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.issue.SecurityIssueAnalysisService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.*; import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Query; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import org.springframework.data.mongodb.core.query.*; +import org.mockito.ArgumentCaptor; +import com.mongodb.client.result.UpdateResult; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; class VersionPublishingServiceTest { - + private net.modtale.service.security.scan.WardenClientService warden; + private MongoTemplate mongo; + private ProjectNotificationService notifications; private VersionPublishingService service; - private MongoTemplate mongoTemplate; - private ProjectRepository projectRepository; - private ProjectService projectService; - private ProjectNotificationService projectNotificationService; - private SecurityIssueAnalysisService securityIssueAnalysisService; - - @BeforeEach - void setUp() { - mongoTemplate = mock(MongoTemplate.class); - projectRepository = mock(ProjectRepository.class); - projectService = mock(ProjectService.class); - projectNotificationService = mock(ProjectNotificationService.class); - securityIssueAnalysisService = mock(SecurityIssueAnalysisService.class); - - ScheduledReleaseQueryService queryService = new ScheduledReleaseQueryService(mongoTemplate); - ScheduledReleaseExecutionService executionService = new ScheduledReleaseExecutionService( - projectRepository, - projectService, - projectNotificationService, - securityIssueAnalysisService - ); - service = new VersionPublishingService(queryService, executionService); + @BeforeEach void setup() { + warden = mock(net.modtale.service.security.scan.WardenClientService.class); + when(warden.currentPolicyVersion()).thenReturn(ScanEvidenceFixtures.complete(true).getSecurityEvidence().policyVersion()); + mongo = mock(MongoTemplate.class); + notifications = mock(ProjectNotificationService.class); + service = new VersionPublishingService(new ScheduledReleaseQueryService(mongo), + new ScheduledReleaseExecutionService(mongo, mock(ProjectService.class), notifications, mock(SecurityIssueAnalysisService.class), warden)); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))).thenReturn(UpdateResult.acknowledged(1, 1L, null)); } - - @Test - void processScheduledReleasesPublishesOnlyDueVersions() { - Project project = new Project(); - project.setId("project-1"); - ProjectVersion dueVersion = version("1.0.0", "2000-01-01T00:00:00"); - ProjectVersion futureVersion = version("2.0.0", "2999-01-01T00:00:00"); - project.setVersions(new ArrayList<>(List.of(dueVersion, futureVersion))); - - when(mongoTemplate.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); - + @Test void publishesOnlyDueVersionsWithVerifiedClearance() { + Project project = project(true); + var future = version("2.0", "2999-01-01T00:00:00", true); + project.setVersions(List.of(project.getVersions().getFirst(), future)); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); service.processScheduledReleases(); - - assertEquals(ProjectVersion.ReviewStatus.APPROVED, dueVersion.getReviewStatus()); - assertNull(dueVersion.getScheduledPublishDate()); - assertEquals(ProjectVersion.ReviewStatus.SCHEDULED, futureVersion.getReviewStatus()); - assertEquals("2999-01-01T00:00:00", futureVersion.getScheduledPublishDate()); - assertNotNull(project.getUpdatedAt()); - - verify(securityIssueAnalysisService).pruneApprovedScanResults(project); - verify(projectRepository).save(project); - verify(projectService).evictProjectCache(project); - verify(projectNotificationService).notifyUpdates(project, "1.0.0"); - verify(projectNotificationService).notifyDependents(project, "1.0.0"); - verify(projectNotificationService, never()).notifyUpdates(project, "2.0.0"); - verify(projectNotificationService, never()).notifyDependents(project, "2.0.0"); + verify(notifications).notifyUpdates(project, "1.0"); + verify(notifications, never()).notifyUpdates(project, "2.0"); + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(query.capture(), any(Update.class), eq(Project.class)); + String filter = query.getValue().getQueryObject().toString(); + assertTrue(filter.contains("SCHEDULED")); + assertTrue(filter.contains("scanResult.scanAttempt")); + assertTrue(filter.contains("artifactSha256")); } - - private static ProjectVersion version(String versionNumber, String scheduledPublishDate) { - ProjectVersion version = new ProjectVersion(); - version.setId("version-" + versionNumber); - version.setVersionNumber(versionNumber); - version.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); - version.setScheduledPublishDate(scheduledPublishDate); - return version; + @Test void refusesLegacyScheduledApprovalWithoutEvidence() { + Project project = project(false); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + service.processScheduledReleases(); + verifyNoInteractions(notifications); + ArgumentCaptor update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, ((org.bson.Document)update.getValue().getUpdateObject().get("$set")).get("versions.$.reviewStatus")); + } + @Test void concurrentRescanOrRejectionPreventsReleaseAndNotification() { + Project project = project(true); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))).thenReturn(UpdateResult.acknowledged(0, 0L, null)); + service.processScheduledReleases(); + verifyNoInteractions(notifications); + } + @Test void changedContextOrSupplementalFilesCannotInheritScheduledClearance() { + for (String scenario : List.of("override", "game", "manifest", "missing-context", "future-scan")) { + reset(mongo, notifications); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + Project project = project(true); + var version = project.getVersions().getFirst(); + switch (scenario) { + case "override" -> version.setOverrideFileUrl("separate.zip"); + case "game" -> version.setGameVersions(List.of("changed-runtime")); + case "manifest" -> version.setManifestId("changed-entrypoint"); + case "missing-context" -> version.getScanResult().setReviewedContextSha256(null); + case "future-scan" -> version.getScanResult().setScanTimestamp(System.currentTimeMillis() + 60_000); + } + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + service.processScheduledReleases(); + verifyNoInteractions(notifications); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, + ((org.bson.Document) update.getValue().getUpdateObject().get("$set")).get("versions.$.reviewStatus"), scenario); + } + } + @Test void unavailablePolicyDefersWithoutChangingQueueOrNotifying() { + var project = project(true); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + when(warden.currentPolicyVersion()).thenReturn(null); + service.processScheduledReleases(); + verify(mongo, never()).updateFirst(any(Query.class), any(Update.class), eq(Project.class)); + verifyNoInteractions(notifications); + } + @Test void changedPolicyInvalidatesScheduledClearance() { + var project = project(true); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + when(warden.currentPolicyVersion()).thenReturn("warden-3.0.0:" + "c".repeat(64)); + service.processScheduledReleases(); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, + ((org.bson.Document) update.getValue().getUpdateObject().get("$set")).get("versions.$.reviewStatus")); + verifyNoInteractions(notifications); + } + private Project project(boolean verified) { + Project p = new Project(); p.setId("project"); p.setVersions(List.of(version("1.0", "2000-01-01T00:00:00", verified))); return p; + } + private ProjectVersion version(String number, String date, boolean verified) { + ProjectVersion v = new ProjectVersion(); v.setId(number); v.setVersionNumber(number); + v.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); v.setScheduledPublishDate(date); + if (verified) { v.setScanResult(ScanEvidenceFixtures.complete(true)); v.setHash(v.getScanResult().getSecurityEvidence().artifactSha256()); v.getScanResult().setReviewedContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(v)); } + return v; } } diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java index 82827353f..7841cfd35 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.mock.web.MockMultipartFile; +import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -39,6 +40,7 @@ class VersionServiceTest { private VersionService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private ProjectAccessService projectAccessService; @@ -56,6 +58,11 @@ class VersionServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(true); projectService = mock(ProjectService.class); accessControlService = mock(AccessControlService.class); projectAccessService = new ProjectAccessService(projectService, accessControlService); @@ -63,6 +70,11 @@ void setUp() { validationService = mock(ValidationService.class); projectVersionAccessService = new ProjectVersionAccessService(validationService); scanService = mock(ScanService.class); + when(scanService.nextScanAttempt(any())).thenReturn(1); + when(scanService.createQueuedScanResult(anyInt(), anyString())).thenAnswer(invocation -> { + var queued = new ScanResult(); queued.setScanAttempt(invocation.getArgument(0)); return queued; + }); + when(reviewPersistence.applyVersionList(any())).thenReturn(true); sanitizationService = mock(SanitizationService.class); versionArtifactService = mock(VersionArtifactService.class); versionDependencyService = mock(VersionDependencyService.class); @@ -77,7 +89,7 @@ void setUp() { projectDeletionService ); VersionCreationCommandHandler versionCreationCommandHandler = new VersionCreationCommandHandler( - projectRepository, + reviewPersistence, projectService, projectAccessService, projectMutationGuard, @@ -85,7 +97,7 @@ void setUp() { new AppLimitProperties(10, 5, 10, 5, 5, 50, 20, 10) ); VersionUpdateCommandHandler versionUpdateCommandHandler = new VersionUpdateCommandHandler( - projectRepository, + reviewPersistence, projectService, projectAccessService, projectMutationGuard, @@ -94,7 +106,7 @@ void setUp() { ); service = new VersionService( - projectRepository, + reviewPersistence, projectService, projectAccessService, projectMutationGuard, @@ -128,6 +140,7 @@ void addVersionQueuesInitialScanForNonModpackArtifacts() throws Exception { when(validationService.getAllowedGameVersions()).thenReturn(List.of("1.21.0")); when(versionArtifactService.prepareVersionArtifact(project, file)) .thenReturn(new VersionArtifactService.PreparedVersionArtifact(ProjectClassification.DATA, "/files/data/bundle.zip", "sha-256")); + queuedScan.setScanRequestId("initial-request"); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); DependencyReferenceRequest dependency = dependency("dep-1", "2.0.0"); when(versionDependencyService.resolveRequestedDependencies(List.of(dependency), false, false)) @@ -150,9 +163,9 @@ void addVersionQueuesInitialScanForNonModpackArtifacts() throws Exception { assertEquals("/files/data/bundle.zip", savedVersion.getFileUrl()); assertEquals("sha-256", savedVersion.getHash()); assertEquals(queuedScan, savedVersion.getScanResult()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); - verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1); + verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1, queuedScan.getScanRequestId()); } @Test @@ -191,10 +204,10 @@ void addVersionDoesNotQueueScanForDraftArtifacts() throws Exception { ProjectVersion savedVersion = project.getVersions().getFirst(); assertNull(savedVersion.getScanResult()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); verify(scanService, never()).createQueuedScanResult(1, "Initial scan queued."); - verify(scanService, never()).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1); + verify(scanService, never()).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1, null); } @Test @@ -268,6 +281,16 @@ void addVersionReplacesDuplicateGameTargetWhenConsentedAndReturnsToPendingReview .thenReturn(new VersionArtifactService.PreparedVersionArtifact(ProjectClassification.DATA, "/files/data/replacement.zip", "sha-replacement")); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); + when(reviewPersistence.applyVersionList(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.addVersion( + "project-1", "1.0.0", List.of("1.21.0"), file, "Replacement notes", null, null, + ProjectVersion.Channel.RELEASE, true, user)); + verify(projectDeletionService, never()).deleteVersionFile(any(ProjectVersion.class)); + verify(scanService, never()).enqueueBackgroundScan(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyInt(), any()); + project.setVersions(new ArrayList<>(List.of(existing))); + clearInvocations(reviewPersistence, scanService); + when(reviewPersistence.applyVersionList(any())).thenReturn(true); + service.addVersion( "project-1", "1.0.0", @@ -290,9 +313,9 @@ void addVersionReplacesDuplicateGameTargetWhenConsentedAndReturnsToPendingReview assertEquals(0, savedVersion.getDownloadCount()); assertEquals(queuedScan, savedVersion.getScanResult()); verify(projectDeletionService).deleteVersionFile(existing); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); - verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/replacement.zip", "replacement.zip", false, 1); + verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/replacement.zip", "replacement.zip", false, 1, null); } @Test @@ -340,7 +363,8 @@ void addVersionReplacementKeepsUntouchedGameTargetsOnExistingVersion() throws Ex assertEquals(List.of("1.21.0"), project.getVersions().getFirst().getGameVersions()); assertEquals("version-old", project.getVersions().get(1).getId()); assertEquals(List.of("1.20.0"), project.getVersions().get(1).getGameVersions()); - assertEquals(ProjectVersion.ReviewStatus.APPROVED, project.getVersions().get(1).getReviewStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, project.getVersions().get(1).getReviewStatus()); + verify(scanService).enqueueBackgroundScan("project-1", "version-old", "/files/data/old.zip", "/files/data/old.zip", false, 1, null); verify(projectDeletionService, never()).deleteVersionFile(existing); } @@ -370,6 +394,15 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { List.of("new-dep") )); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.updateVersion( + "project-1", "version-1", List.of(dependency), List.of(), null, null, null, user)); + verify(projectDeletionService, never()).deleteStoredFile(anyString()); + version.setFileUrl("modpacks/sky-pack-1.0.0.zip"); + version.setDependencies(new ArrayList<>(List.of(new ProjectDependency("old-dep", "Old Dependency", "1.0.0")))); + clearInvocations(reviewPersistence); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(true); + service.updateVersion( "project-1", "version-1", @@ -385,10 +418,42 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { assertEquals(List.of("new-dep"), project.getChildProjectIds()); assertEquals("new-dep", version.getDependencies().getFirst().getProjectId()); verify(projectDeletionService).deleteStoredFile("modpacks/sky-pack-1.0.0.zip"); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionEdit(any(), eq("version-1"), eq(true), eq(true)); + verify(projectRepository, never()).save(any()); verify(projectService).evictProjectCache(project); } + @Test + void contextEditQueuesOnlyAfterSuccessfulConditionalPersistence() { + var project = new Project(); project.setId("project-1"); project.setStatus(ProjectStatus.PUBLISHED); + project.setClassification(ProjectClassification.PLUGIN); + var version = new ProjectVersion(); version.setId("version-1"); version.setFileUrl("mods/mod.jar"); + version.setGameVersions(List.of("old")); version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + project.setVersions(new ArrayList<>(List.of(version))); + var user = new User(); user.setId("user-1"); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.hasProjectPermission(project, user, "VERSION_EDIT")).thenReturn(true); + when(validationService.isGameVersionSupported("new")).thenReturn(true); + when(scanService.nextScanAttempt(null)).thenReturn(2); + var queued = new ScanResult(); queued.setScanAttempt(2); + when(scanService.createQueuedScanResult(eq(2), anyString())).thenReturn(queued); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.updateVersion( + "project-1", "version-1", null, null, List.of("new"), null, null, user)); + verify(scanService, never()).enqueueBackgroundScan(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyInt(), any()); + verify(projectService, never()).evictProjectCache(any()); + verify(projectRepository, never()).save(any()); + clearInvocations(reviewPersistence, scanService); + // A fresh request succeeds and uses the newly prepared attempt after the database accepts it. + version.setGameVersions(List.of("old")); version.setScanResult(null); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(true); + service.updateVersion("project-1", "version-1", null, null, List.of("new"), null, null, user); + var order = inOrder(reviewPersistence, scanService); + order.verify(reviewPersistence).applyVersionEdit(any(), eq("version-1"), eq(true), eq(false)); + order.verify(scanService).enqueueBackgroundScan("project-1", "version-1", "mods/mod.jar", "mods/mod.jar", false, 2, queued.getScanRequestId()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version.getReviewStatus()); + } + @Test void deleteVersionAllowsPrivateProjectsToRemoveTheirLastVersion() { Project project = new Project(); @@ -406,11 +471,18 @@ void deleteVersionAllowsPrivateProjectsToRemoveTheirLastVersion() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "VERSION_DELETE")).thenReturn(true); + when(reviewPersistence.applyVersionList(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.deleteVersion("project-1", "version-1", user)); + verify(projectDeletionService, never()).deleteVersionFile(any(ProjectVersion.class)); + project.setVersions(new ArrayList<>(List.of(version))); + clearInvocations(reviewPersistence); + when(reviewPersistence.applyVersionList(any())).thenReturn(true); + service.deleteVersion("project-1", "version-1", user); assertEquals(0, project.getVersions().size()); verify(projectDeletionService).deleteVersionFile(version); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); } diff --git a/backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java b/backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java new file mode 100644 index 000000000..0c440beef --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java @@ -0,0 +1,129 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class FindingDecisionValidityTest { + private static final long NOW = 1_800_000_000_000L; + private final FindingDecisionValidity evaluator = new FindingDecisionValidity(); + private ProjectVersion version() { + var version = new ProjectVersion(); version.setId("v"); + var scan = ScanEvidenceFixtures.complete(false); version.setScanResult(scan); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); issue.setType("Network"); + issue.setDescription("Connects to a service"); issue.setSeverity("LOW"); issue.setLineStart(9); issue.setLineEnd(11); + scan.setIssues(new ArrayList<>(List.of(issue))); return version; + } + private FindingReviewService.Event event(ProjectVersion version, String id, FindingReviewService.Disposition disposition, + String revoked, String supersedes, long created, long expires) { + var scan = version.getScanResult(); var e = scan.getSecurityEvidence(); var issue = scan.getIssues().getFirst(); + return new FindingReviewService.Event(id, "p", "v", null, 1, "reviewer", created, expires, disposition, + "Inspected the complete integration", "WHOLE_ARTIFACT", e.artifactSha256(), e.contentSha256(), e.policyVersion(), + ArtifactReviewContext.fingerprint(version), new FindingReviewService.Finding(IssueEvidenceIdentity.from(scan).identify(issue), + issue.getFilePath(), issue.getType(), issue.getDescription(), issue.getLineStart(), issue.getLineEnd()), revoked, supersedes); + } + private FindingReviewService.Event acceptance(ProjectVersion version) { + return event(version, "accepted", FindingReviewService.Disposition.ACCEPT, null, null, NOW - 1000, NOW + 1000); + } + private FindingDecisionValidity.State state(ProjectVersion version, FindingReviewService.Event event, String policy) { + return evaluator.assess("p", version, List.of(event), policy, NOW).get(event.id()).state(); + } + @Test void matchingReasoningDoesNotMutateClearanceOrResolveTheFinding() { + var version = version(); var event = acceptance(version); + assertEquals(FindingDecisionValidity.State.APPLICABLE, state(version, event, event.policyVersion())); + assertFalse(version.getScanResult().getIssues().getFirst().isResolved()); + assertFalse(ArtifactClearancePolicy.cleared(version.getScanResult())); + } + @Test void artifactCallerResourceAndDependencyChangesInvalidateWholeArtifactScope() { + for (String change : List.of("caller", "resource", "dependency", "runtime", "supplement", "unbound")) { + var version = version(); var event = acceptance(version); var scan = version.getScanResult(); + FindingDecisionValidity.State expected; + if (change.equals("caller") || change.equals("resource")) { + var e = scan.getSecurityEvidence(); + var entries = new HashMap<>(e.entryHashes()); entries.put(change + ".data", "d".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), SecurityManifest.identity(entries), + true, false, e.reviewState(), entries)); + expected = FindingDecisionValidity.State.ARTIFACT_CHANGED; + } else if (change.equals("unbound")) { + version.setHash("d".repeat(64)); expected = FindingDecisionValidity.State.ARTIFACT_CHANGED; + } else { + if (change.equals("runtime")) version.setGameVersions(List.of("new-runtime")); + else if (change.equals("dependency")) version.setDependencies(List.of(new ProjectDependency("dependency", "Dependency", "1.0"))); + else version.setOverrideFileUrl("supplement.zip"); + expected = FindingDecisionValidity.State.CONTEXT_CHANGED; + } + assertEquals(expected, state(version, event, event.policyVersion()), change); + } + } + @Test void sameVerifiedContentIdentityAllowsPackagingChange() { + var version = version(); var event = acceptance(version); var scan = version.getScanResult(); var e = scan.getSecurityEvidence(); + version.setHash("d".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), version.getHash(), e.contentSha256(), true, false, e.reviewState(), e.entryHashes())); + assertEquals(FindingDecisionValidity.State.APPLICABLE, state(version, event, event.policyVersion())); + } + @Test void newerPolicyUnavailablePolicyAndChangedOccurrenceCannotRetainAcceptance() { + var version = version(); var event = acceptance(version); + assertEquals(FindingDecisionValidity.State.POLICY_UNAVAILABLE, state(version, event, null)); + assertEquals(FindingDecisionValidity.State.POLICY_CHANGED, state(version, event, "warden-3.0.0:" + "f".repeat(64))); + version.getScanResult().getIssues().getFirst().setLineStart(10); + assertEquals(FindingDecisionValidity.State.FINDING_CHANGED, state(version, event, event.policyVersion())); + } + @Test void incompleteOrAdverseEvidenceCannotBorrowAnAcceptance() { + for (String change : List.of("unverified", "incomplete", "missing-manifest", "block", "new-evidence")) { + var version = version(); var event = acceptance(version); var scan = version.getScanResult(); var e = scan.getSecurityEvidence(); + switch (change) { + case "unverified" -> scan.setArtifactVerified(false); + case "incomplete" -> scan.setScanState("INCOMPLETE"); + case "missing-manifest" -> scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), e.contentSha256(), true, false, e.reviewState(), Map.of())); + case "block" -> scan.setVerdict("BLOCK"); + case "new-evidence" -> scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), e.contentSha256(), true, false, "NEW_SECURITY_EVIDENCE", e.entryHashes())); + } + assertEquals(change.equals("block") || change.equals("new-evidence") ? FindingDecisionValidity.State.ADVERSE_EVIDENCE + : FindingDecisionValidity.State.INCOMPLETE_EVIDENCE, state(version, event, event.policyVersion()), change); + } + } + @Test void expiryFutureDatesAndCrossVersionProvenanceAreChecked() { + var version = version(); var event = acceptance(version); + assertEquals(FindingDecisionValidity.State.EXPIRED, evaluator.assess("p", version, List.of(event), event.policyVersion(), NOW + 1000).get(event.id()).state()); + assertEquals(FindingDecisionValidity.State.INVALID_RECORD, evaluator.assess("p", version, List.of(event), event.policyVersion(), NOW - 1001).get(event.id()).state()); + assertEquals(FindingDecisionValidity.State.INVALID_RECORD, evaluator.assess("different-project", version, List.of(event), event.policyVersion(), NOW).get(event.id()).state()); + version.setId("different-version"); + assertEquals(FindingDecisionValidity.State.INVALID_RECORD, state(version, event, event.policyVersion())); + } + @Test void revokedReplacementDoesNotResurrectSupersededAcceptance() { + var version = version(); var first = acceptance(version); + var second = event(version, "replacement", FindingReviewService.Disposition.REQUIRE_REVIEW, null, first.id(), NOW - 500, 0); + var revoke = event(version, "revocation", FindingReviewService.Disposition.REVOKE, second.id(), null, NOW, 0); + var result = evaluator.assess("p", version, List.of(revoke, second, first), first.policyVersion(), NOW); + assertEquals(FindingDecisionValidity.State.SUPERSEDED, result.get(first.id()).state()); + assertEquals(FindingDecisionValidity.State.REVOKED, result.get(second.id()).state()); + assertEquals(FindingDecisionValidity.State.REVOCATION, result.get(revoke.id()).state()); + } + @Test void extendedExpiryUnknownScopeAndMisrepresentedFindingCannotApply() { + for (String change : List.of("expiry", "scope", "description")) { + var version = version(); var original = acceptance(version); + var finding = original.finding(); + if (change.equals("description")) finding = new FindingReviewService.Finding(finding.identity(), finding.path(), + finding.type(), "Different displayed evidence", finding.lineStart(), finding.lineEnd()); + var altered = new FindingReviewService.Event(original.id(), original.projectId(), original.versionId(), original.previousId(), + original.sequence(), original.actorId(), original.createdAt(), change.equals("expiry") ? NOW + 31L * 86400000 : original.expiresAt(), + original.disposition(), original.rationale(), change.equals("scope") ? "ANY_PROJECT" : original.scope(), original.artifactSha256(), + original.contentSha256(), original.policyVersion(), original.contextSha256(), finding, null, null); + var expected = switch (change) { + case "expiry" -> FindingDecisionValidity.State.INVALID_RECORD; + case "scope" -> FindingDecisionValidity.State.UNSUPPORTED_SCOPE; + default -> FindingDecisionValidity.State.FINDING_CHANGED; + }; + assertEquals(expected, state(version, altered, altered.policyVersion()), change); + } + } + @Test void reviewRequirementsNeverExpireIntoAcceptance() { + var version = version(); var hold = event(version, "hold", FindingReviewService.Disposition.REQUIRE_REVIEW, null, null, NOW - 1000, 0); + assertEquals(FindingDecisionValidity.State.REVIEW_REQUIRED, + evaluator.assess("p", version, List.of(hold), hold.policyVersion(), NOW + 100L * 86400000).get(hold.id()).state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java b/backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java new file mode 100644 index 000000000..21fbe01e0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java @@ -0,0 +1,177 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.config.properties.AppSecurityProperties; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class IssueEvidenceIdentityTest { + private final SecurityIssueEvaluationService evaluation = new SecurityIssueEvaluationService(120); + + @Test void approvalPruningRetainsEvidenceAndNewScanRemainsUnresolved() { + var prior = approved(scan()); + assertNull(prior.getScanResult()); + assertNotNull(prior.getApprovedIssueBaselines().getFirst().getEvidenceIdentity()); + var current = scan(); + annotate(current, prior); + assertTrue(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + assertFalse(current.getIssues().getFirst().isResolved()); + assertEquals("REVIEW", current.getVerdict()); + } + + @Test void changedFileBehindSameWarningIsNotIdentical() { + var prior = approved(scan()); + var current = scan(); + replaceEntries(current, Map.of("Mod.class", "c".repeat(64))); + annotate(current, prior); + assertTrue(current.getIssues().getFirst().isKnownIssue()); + assertFalse(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + } + + @Test void changedCallerOrRuntimeCannotTurnSameFileEvidenceIntoClearance() { + var prior = approved(scan()); + var current = scan(); + replaceEntries(current, Map.of("Mod.class", "a".repeat(64), "Caller.class", "d".repeat(64))); + current.setReviewedContextSha256("e".repeat(64)); + annotate(current, prior); + assertTrue(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + assertFalse(current.getIssues().getFirst().isResolved()); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.cleared(current)); + } + + @Test void exactOccurrencePolicyAndSemanticFieldsAreBound() { + var original = scan(); + String expected = IssueEvidenceIdentity.from(original).identify(original.getIssues().getFirst()); + for (String change : List.of("line", "end", "type", "description", "severity", "confidence", "cadence", "tactics", "policy")) { + var changed = scan(); + var issue = changed.getIssues().getFirst(); + switch (change) { + case "line" -> issue.setLineStart(10); // Same legacy eight-line bucket. + case "end" -> issue.setLineEnd(12); + case "type" -> issue.setType("Network|changed"); + case "description" -> issue.setDescription("connect|changed"); + case "severity" -> issue.setSeverity("HIGH"); + case "confidence" -> issue.setConfidence(99); + case "cadence" -> issue.setReviewCadence("ALWAYS"); + case "tactics" -> issue.setTactics(List.of("exfiltration")); + case "policy" -> { + var e = changed.getSecurityEvidence(); + changed.setSecurityEvidence(new ScanResult.SecurityEvidence("warden-3.0.0:" + "f".repeat(64), + e.artifactSha256(), e.contentSha256(), true, false, e.reviewState(), e.entryHashes())); + } + } + assertNotEquals(expected, IssueEvidenceIdentity.from(changed).identify(issue), change); + } + } + + @Test void missingUnverifiedIncompleteCorruptOrUnmappedEvidenceCannotMatch() { + for (String change : List.of("missing", "unverified", "incomplete", "corrupt", "unmapped")) { + var scan = scan(); + switch (change) { + case "missing" -> scan.setSecurityEvidence(null); + case "unverified" -> scan.setArtifactVerified(false); + case "incomplete" -> scan.setScanState("FAILED"); + case "corrupt" -> { + var e = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), + "f".repeat(64), true, false, e.reviewState(), e.entryHashes())); + } + case "unmapped" -> scan.getIssues().getFirst().setFilePath("Other.class"); + } + assertNull(IssueEvidenceIdentity.from(scan).identify(scan.getIssues().getFirst()), change); + } + } + + @Test void legacyHistoryAndUnboundApprovalCannotInventEvidence() { + var prior = approved(scan()); + prior.getApprovedIssueBaselines().getFirst().setEvidenceIdentity(null); + var current = scan(); + current.getIssues().getFirst().setHistoricalFileEvidenceIdentical(true); + annotate(current, prior); + assertFalse(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + var version = new ProjectVersion(); + version.setHash("f".repeat(64)); + version.setScanResult(scan()); + approval().markIssuesAcceptedForApprovedVersion(version); + assertNull(version.getApprovedIssueBaselines().getFirst().getEvidenceIdentity()); + } + + @Test void fieldBoundariesNullsAndMalformedUnicodeCannotAlias() { + var scan = scan(); var issue = scan.getIssues().getFirst(); + var identities = IssueEvidenceIdentity.from(scan); + Set hashes = new HashSet<>(); + for (String value : Arrays.asList(null, "", "null", "?", String.valueOf((char) 0xd800), + String.valueOf((char) 0xd801), "a|b")) { + issue.setDescription(value); + assertTrue(hashes.add(identities.identify(issue))); + } + issue.setType("a|b"); issue.setCategory("c"); + String before = identities.identify(issue); + issue.setType("a"); issue.setCategory("b|c"); + assertNotEquals(before, identities.identify(issue)); + } + + @Test void mergedHistoryKeepsIdentityFromTheDisplayedVersion() { + var a = new SecurityIssueAnalysisService.IssueBaseline("si", "sl", "LOW", 0, 0, "a", "1", 1, 1, "first"); + var b = new SecurityIssueAnalysisService.IssueBaseline("si", "sl", "HIGH", 2, 8, "b", "2", 2, 1, "second"); + assertEquals("second", a.mergeWith(b).evidenceIdentity()); + assertEquals("b", a.mergeWith(b).versionId()); + assertEquals("second", b.mergeWith(a).evidenceIdentity()); + } + + @Test void existingDecisionIdentityRemainsByteCompatible() { + var scan = scan(); var issue = scan.getIssues().getFirst(); + issue.setScoreImpact(7); issue.setConfidence(81); issue.setEvidenceLevel("BEHAVIORAL"); + issue.setReviewCadence("WHEN_CHANGED"); issue.setReviewPriority("MEDIUM"); issue.setTactics(List.of("NETWORK")); + assertEquals("ie1:950d94eaa1e0b2e30188bdc3973219e4d450ffbd72ecaa8880c899321adab9b2", IssueEvidenceIdentity.from(scan).identify(issue)); + } + @Test void reasoningIdentityIgnoresOnlyScoringAndStillBindsSecurityEvidence() { + var scan = scan(); var issue = scan.getIssues().getFirst(); var identities = IssueEvidenceIdentity.from(scan); + String exact = identities.identify(issue), reasoning = identities.reasoningIdentity(issue); + issue.setScoreImpact(40); issue.setConfidence(90); issue.setReviewPriority("HIGH"); issue.setNoiseSuppressed(true); + assertNotEquals(exact, identities.identify(issue)); assertEquals(reasoning, identities.reasoningIdentity(issue)); + issue.setSeverity("HIGH"); assertNotEquals(reasoning, identities.reasoningIdentity(issue)); + var changed = scan(); replaceEntries(changed, Map.of("Mod.class", "c".repeat(64))); + assertNotEquals(reasoning, IssueEvidenceIdentity.from(changed).reasoningIdentity(changed.getIssues().getFirst())); + } + @Test void approvalRetainsReasoningIdentityAndOldSnapshotsOmitAbsentField() throws Exception { + var scan = scan(); String expected = IssueEvidenceIdentity.from(scan).reasoningIdentity(scan.getIssues().getFirst()); + var prior = approved(scan); + assertEquals(expected, prior.getApprovedIssueBaselines().getFirst().getReasoningEvidenceIdentity()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + assertFalse(mapper.valueToTree(new ProjectVersion.ApprovedIssueBaseline()).has("reasoningEvidenceIdentity")); + var restored = mapper.readValue(mapper.writeValueAsBytes(prior), ProjectVersion.class); + assertEquals(expected, restored.getApprovedIssueBaselines().getFirst().getReasoningEvidenceIdentity()); + } + + private void annotate(ScanResult current, ProjectVersion prior) { + var project = new Project(); project.setVersions(List.of(prior)); + evaluation.annotateAgainstBaselines(current, + new SecurityIssueBaselineService().collectApprovedIssueBaselines(project, null, evaluation)); + } + private SecurityIssueApprovalService approval() { + return new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test", 60, 120, 2, 12, 15, 120, 25, 2))); + } + private ProjectVersion approved(ScanResult scan) { + var version = new ProjectVersion(); version.setId("prior"); version.setVersionNumber("1.0"); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + version.setHash(scan.getSecurityEvidence().artifactSha256()); version.setScanResult(scan); + approval().markIssuesAcceptedForApprovedVersion(version); + return version; + } + private ScanResult scan() { + var scan = ScanEvidenceFixtures.complete(false); + var issue = new ScanResult.ScanIssue(); issue.setType("Network"); issue.setCategory("NETWORK"); + issue.setDescription("connect"); issue.setFilePath("Mod.class"); issue.setSeverity("LOW"); + issue.setLineStart(9); issue.setLineEnd(11); + scan.setIssues(new ArrayList<>(List.of(issue))); + return scan; + } + private void replaceEntries(ScanResult scan, Map entries) { + var e = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), "c".repeat(64), + SecurityManifest.identity(entries), true, false, e.reviewState(), entries)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java new file mode 100644 index 000000000..a061b169f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java @@ -0,0 +1,156 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.*; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PriorFindingReasoningServiceTest { + static class Fixture { + final ProjectService projects=mock(ProjectService.class); + final MongoTemplate mongo=mock(MongoTemplate.class); + final PriorFindingReasoningService service=new PriorFindingReasoningService(projects,mongo); + final Project project=new Project();final ProjectVersion source=new ProjectVersion(),target=new ProjectVersion(); + final FindingReviewService.Event event; + Fixture() { + project.setId("project");source.setId("source");source.setVersionNumber("1");target.setId("target");target.setVersionNumber("2"); + var scan=ScanEvidenceFixtures.complete(false);target.setScanResult(scan);target.setHash(scan.getSecurityEvidence().artifactSha256()); + var issue=new ScanResult.ScanIssue();issue.setFilePath("Mod.class");issue.setType("Network");issue.setDescription("Connects to documented service");issue.setSeverity("LOW");issue.setLineStart(9);issue.setLineEnd(11);scan.setIssues(new ArrayList<>(List.of(issue))); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(target)); + source.setHash(target.getHash());source.setApprovedSecurityEvidence(scan.getSecurityEvidence()); + source.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(source)); + source.setSecurityApprovedAt(System.currentTimeMillis()-1000);source.setSecurityApprovalProjectId("project"); + source.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);source.setApprovedReviewOrigins(Map.of()); + source.setFindingReviewHead("event");source.setApprovedFindingReviewHead("event"); + String identity=IssueEvidenceIdentity.from(scan).identify(issue);var baseline=new ProjectVersion.ApprovedIssueBaseline();baseline.setEvidenceIdentity(identity);source.setApprovedIssueBaselines(List.of(baseline)); + var evidence=scan.getSecurityEvidence(); + event=new FindingReviewService.Event("event","project","source",null,1,"reviewer",source.getSecurityApprovedAt()-1000,System.currentTimeMillis()+86400000, + FindingReviewService.Disposition.ACCEPT,"Verified the documented service integration","WHOLE_ARTIFACT",evidence.artifactSha256(),evidence.contentSha256(),evidence.policyVersion(),source.getApprovedSecurityContextSha256(), + new FindingReviewService.Finding(identity,issue.getFilePath(),issue.getType(),issue.getDescription(),9,11),null,null); + project.setVersions(List.of(source,target));when(projects.getRawProjectById("project")).thenReturn(project); + when(mongo.findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(event); + } + PriorFindingReasoningService.Reasoning read(){return service.read("project","target","source",0,ProjectReviewSnapshot.token(project));} + } + @Test void returnsExactApprovedReasoningAcrossChangedCallerAndRuntimeWithoutResolving() { + var f=new Fixture();var scan=f.target.getScanResult();var e=scan.getSecurityEvidence();var entries=new HashMap<>(e.entryHashes());entries.put("Caller.class","c".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),SecurityManifest.identity(entries),true,false,e.reviewState(),entries)); + f.target.setGameVersions(List.of("new-runtime"));scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(f.target)); + var result=f.read();assertEquals(List.of(f.event),result.decisions()); + assertTrue(result.reviewReasons().stream().anyMatch(v->v.contains("Artifact contents changed"))); + assertTrue(result.reviewReasons().stream().anyMatch(v->v.contains("context changed"))); + assertFalse(scan.getIssues().getFirst().isResolved());assertFalse(ArtifactClearancePolicy.cleared(scan)); + verify(f.mongo,never()).insert(any(),anyString()); + } + @Test void changedFindingFileOrDescriptionCannotBorrowIdenticalLookingReasoning() { + for(boolean content:new boolean[]{true,false}) { + var f=new Fixture();var scan=f.target.getScanResult();var e=scan.getSecurityEvidence(); + if(content){var entries=Map.of("Mod.class","c".repeat(64));scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),SecurityManifest.identity(entries),true,false,e.reviewState(),entries));} + else scan.getIssues().getFirst().setDescription("Changed endpoint reasoning"); + assertTrue(f.read().decisions().isEmpty()); + } + } + @Test void staleOrIncompleteCurrentEvidenceCannotReadAsVerifiedMatch() { + var f=new Fixture();assertThrows(ResponseStatusException.class,()->f.service.read("project","target","source",0,"old"));verifyNoInteractions(f.mongo); + f.target.getScanResult().setArtifactVerified(false);assertThrows(ResponseStatusException.class,f::read);verifyNoInteractions(f.mongo); + } + @Test void unapprovedCopiedExpiredOrEditedSourceCannotSupplyVerifiedReasoning() { + for(int scenario=0;scenario<5;scenario++) { + var f=new Fixture();switch(scenario){case 0->f.source.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED);case 1->f.source.setSecurityApprovalProjectId("another");case 2->f.source.setSecurityApprovedAt(1);case 3->f.source.setFindingReviewHead("changed");case 4->f.source.setGameVersions(List.of("edited"));} + assertThrows(ResponseStatusException.class,f::read);verifyNoInteractions(f.mongo); + } + } + @Test void historyChangesDuringReadRejectTheWholeResponse() { + var f=new Fixture();doAnswer(call->{f.source.setFindingReviewHead("revoked");return f.event;}) + .when(f.mongo).findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION); + assertThrows(ResponseStatusException.class,f::read); + } + @Test void missingAndCrossVersionHistoryFailExplicitly() { + var f=new Fixture();when(f.mongo.findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(null); + assertThrows(ResponseStatusException.class,f::read); + when(f.mongo.findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(new FindingReviewService.Event("event","other","source",null,1,"reviewer",1,2,FindingReviewService.Disposition.ACCEPT,"Reasoning for another project","WHOLE_ARTIFACT",null,null,null,null,f.event.finding(),null,null)); + assertThrows(ResponseStatusException.class,f::read); + } + @Test void revokedAndSupersededAcceptancesNeverReappear() { + for(boolean scoringChanged:new boolean[]{true,false}) for(boolean revoke:new boolean[]{true,false}) { + var f=new Fixture();if(scoringChanged) bindThenChangeScoring(f);var e=f.event; + var next=new FindingReviewService.Event("next","project","source","event",2,e.actorId(),e.createdAt()+1,e.expiresAt(), + revoke?FindingReviewService.Disposition.REVOKE:FindingReviewService.Disposition.ACCEPT,"Corrected earlier review conclusion",e.scope(),e.artifactSha256(),e.contentSha256(),e.policyVersion(),e.contextSha256(),e.finding(),revoke?e.id():null,revoke?null:e.id()); + f.source.setFindingReviewHead("next");f.source.setApprovedFindingReviewHead("next");when(f.mongo.findById("next",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(next); + var result=f.read();assertFalse(result.decisions().contains(e));assertEquals(revoke?List.of():List.of(next),result.decisions()); + } + } + @Test void absentApprovedOccurrenceNeverManufacturesRecordedApproval() { + var f=new Fixture();f.source.setApprovedIssueBaselines(List.of());assertTrue(f.read().decisions().isEmpty()); + } + @Test void scoringChangesCanRetrieveNewlyBoundReasoningWithoutAcceptance() { + var f = new Fixture(); var scan = f.target.getScanResult(); var issue = scan.getIssues().getFirst(); + f.source.getApprovedIssueBaselines().getFirst().setReasoningEvidenceIdentity(IssueEvidenceIdentity.from(scan).reasoningIdentity(issue)); + issue.setScoreImpact(40); issue.setConfidence(90); issue.setReviewPriority("HIGH"); issue.setNoiseSuppressed(true); + var result = f.read(); assertEquals(List.of(f.event), result.decisions()); + assertTrue(result.reviewReasons().stream().anyMatch(r -> r.contains("Risk scoring changed"))); + assertFalse(issue.isResolved()); assertFalse(ArtifactClearancePolicy.cleared(scan)); + verify(f.mongo, never()).insert(any(), anyString()); + } + @Test void legacyApprovalsCannotInventMissingReasoningBindings() { + var f = new Fixture(); f.target.getScanResult().getIssues().getFirst().setConfidence(90); + assertTrue(f.read().decisions().isEmpty()); + } + @Test void newReasoningBindingCannotMatchChangedSecurityEvidence() { + for (String change : List.of("description", "file", "severity", "policy", "category", "type", "start", "end", "evidence", "cadence", "tactics")) { + var f = new Fixture(); var scan = f.target.getScanResult(); var issue = scan.getIssues().getFirst(); + f.source.getApprovedIssueBaselines().getFirst().setReasoningEvidenceIdentity(IssueEvidenceIdentity.from(scan).reasoningIdentity(issue)); + issue.setConfidence(90); + switch (change) { + case "category" -> issue.setCategory("Changed category"); + case "type" -> issue.setType("Changed type"); + case "start" -> issue.setLineStart(8); + case "end" -> issue.setLineEnd(12); + case "evidence" -> issue.setEvidenceLevel("CORRELATED"); + case "cadence" -> issue.setReviewCadence("ALWAYS"); + case "tactics" -> issue.setTactics(List.of("EXECUTION")); + case "policy" -> { var e = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence("warden-3.0.0:" + "c".repeat(64), e.artifactSha256(), e.contentSha256(), true, false, e.reviewState(), e.entryHashes())); } + case "description" -> issue.setDescription("Different destination"); + case "severity" -> issue.setSeverity("HIGH"); + case "file" -> { var e = scan.getSecurityEvidence(); var entries = Map.of("Mod.class", "c".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), SecurityManifest.identity(entries), true, false, e.reviewState(), entries)); } + } + assertTrue(f.read().decisions().isEmpty(), change); + } + } + + private static void bindThenChangeScoring(Fixture f) { + var scan = f.target.getScanResult(); var issue = scan.getIssues().getFirst(); + f.source.getApprovedIssueBaselines().getFirst().setReasoningEvidenceIdentity( + IssueEvidenceIdentity.from(scan).reasoningIdentity(issue)); + issue.setConfidence(90); + } + + @Test void changedReasoningBindingDuringReadRejectsTheWholeResponse() { + var f = new Fixture(); bindThenChangeScoring(f); + doAnswer(call -> { + f.source.getApprovedIssueBaselines().getFirst().setReasoningEvidenceIdentity("re1:" + "a".repeat(64)); + return f.event; + }).when(f.mongo).findById("event", FindingReviewService.Event.class, FindingReviewService.COLLECTION); + assertThrows(ResponseStatusException.class, f::read); + } + + @Test void outstandingReviewRequirementPreventsScoringIndependentReasoning() { + var f = new Fixture(); bindThenChangeScoring(f); var e = f.event; + var next = new FindingReviewService.Event("next", "project", "source", "event", 2, + e.actorId(), e.createdAt() + 1, e.expiresAt(), FindingReviewService.Disposition.REQUIRE_REVIEW, + "New evidence requires investigation", e.scope(), e.artifactSha256(), e.contentSha256(), + e.policyVersion(), e.contextSha256(), e.finding(), null, null); + f.source.setFindingReviewHead("next"); f.source.setApprovedFindingReviewHead("next"); + when(f.mongo.findById("next", FindingReviewService.Event.class, FindingReviewService.COLLECTION)).thenReturn(next); + assertThrows(ResponseStatusException.class, f::read); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java index 9c4a6124b..67d417d41 100644 --- a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java @@ -54,6 +54,44 @@ void approvalKeepsCompactBaselinesAndClearsHeavyScanResult() { assertFalse(issue.isEscalated()); } + @Test + void approvalRetainsDigestAndContextWithoutDuplicatingArchiveManifest() { + ProjectVersion version = approvedVersion("version", "1.0"); + ScanResult scan = net.modtale.service.security.scan.ScanEvidenceFixtures.complete(false); + scan.setIssues(new ArrayList<>()); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version)); + String contentHash = scan.getSecurityEvidence().contentSha256(); + version.setScanResult(scan); + approvalService.markIssuesAcceptedForApprovedVersion(version); + assertEquals(contentHash, version.getApprovedSecurityEvidence().contentSha256()); + assertTrue(version.getApprovedSecurityEvidence().entryHashes().isEmpty()); + assertEquals(64, version.getApprovedSecurityContextSha256().length()); + } + + @Test + void missingOrUnverifiedEvidenceCannotBecomeReusableApproval() { + for (String scenario : List.of("missing", "unverified", "wrong-artifact", "wrong-context")) { + ProjectVersion version = approvedVersion("version", "1.0"); + ScanResult scan = net.modtale.service.security.scan.ScanEvidenceFixtures.complete(false); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version)); + version.setApprovedSecurityEvidence(scan.getSecurityEvidence()); + version.setApprovedSecurityContextSha256("old"); + version.setSecurityApprovedAt(123); + switch (scenario) { + case "unverified" -> scan.setArtifactVerified(false); + case "wrong-artifact" -> version.setHash("c".repeat(64)); + case "wrong-context" -> scan.setReviewedContextSha256("d".repeat(64)); + } + version.setScanResult(scenario.equals("missing") ? null : scan); + approvalService.markIssuesAcceptedForApprovedVersion(version); + assertNull(version.getApprovedSecurityEvidence(), scenario); + assertNull(version.getApprovedSecurityContextSha256(), scenario); + assertEquals(0, version.getSecurityApprovedAt(), scenario); + } + } + @Test void storedBaselinesAreUsedAfterScanResultIsRemoved() { ProjectVersion version = approvedVersion("version-1", "1.0.0"); diff --git a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java index 4b73eade9..827d25884 100644 --- a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java @@ -33,6 +33,15 @@ void collectApprovedIssueBaselinesReturnsEmptyForMissingProjectsOrVersions() { assertTrue(service.collectApprovedIssueBaselines(project, null, evaluationService).loose().isEmpty()); } + @Test void aRetainedBlockCannotSupplyKnownFindingBaselines() { + var version=approvedVersion("held","1.0"); + version.setApprovedIssueBaselines(List.of(new ProjectVersion.ApprovedIssueBaseline("si:held","sl:held","HIGH",9,80,123L))); + var project=new Project();project.setVersions(List.of(version)); + assertEquals(1,service.collectApprovedIssueBaselines(project,null,evaluationService).exact().size()); + version.setReplacementSecurityHold(java.util.UUID.randomUUID().toString()); + var blocked=service.collectApprovedIssueBaselines(project,null,evaluationService); + assertTrue(blocked.exact().isEmpty());assertTrue(blocked.loose().isEmpty()); + } @Test void collectApprovedIssueBaselinesUsesStoredBaselinesAndSkipsExcludedOrUnapprovedVersions() { @@ -69,9 +78,9 @@ void collectApprovedIssueBaselinesUsesStoredBaselinesAndSkipsExcludedOrUnapprove } @Test - void collectApprovedIssueBaselinesBuildsAndMergesBaselinesFromScanResults() { + void collectApprovedIssueBaselinesMergesIdenticalEvidenceFromScanResults() { ScanResult.ScanIssue olderLow = issue("Runtime", "Reflection", "Danger 1", "Foo.class", 1, "LOW", 3, 20); - ScanResult.ScanIssue newerHigh = issue("Runtime", "Reflection", "Danger 2", "Foo.class", 1, "HIGH", 8, 70); + ScanResult.ScanIssue newerHigh = issue("Runtime", "Reflection", "Danger 1", "Foo.class", 1, "HIGH", 8, 70); ProjectVersion older = approvedVersion("version-1", "1.0.0"); older.setReleaseDate("2026-01-01T00:00:00Z"); older.setScanResult(scan(olderLow)); diff --git a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java index 98bd5d2fe..c2c07fa12 100644 --- a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java @@ -24,7 +24,7 @@ void setUp() { } @Test - void fingerprintNormalizesCasePathsLineBucketsAndVolatileDescriptionValues() { + void fingerprintPreservesChangedPathsAndSecurityRelevantDescriptionValues() { ScanResult.ScanIssue first = issue( "Dangerous Call", "Runtime/Reflection", @@ -46,8 +46,8 @@ void fingerprintNormalizesCasePathsLineBucketsAndVolatileDescriptionValues() { 70 ); - assertEquals(service.fingerprint(first), service.fingerprint(second)); - assertEquals(service.looseFingerprint(first), service.looseFingerprint(second)); + assertFalse(service.fingerprint(first).equals(service.fingerprint(second))); + assertFalse(service.looseFingerprint(first).equals(service.looseFingerprint(second))); assertTrue(service.fingerprint(first).startsWith("si:")); assertTrue(service.looseFingerprint(first).startsWith("sl:")); } @@ -107,7 +107,7 @@ void annotateAgainstBaselinesResolvesKnownExactMatchesWithoutEscalation() { assertEquals(0, stats.escalatedIssueCount()); assertTrue(stats.knownOnly()); assertTrue(knownIssue.isKnownIssue()); - assertTrue(knownIssue.isResolved()); + assertFalse(knownIssue.isResolved()); assertFalse(knownIssue.isEscalated()); assertEquals("1.0.0", knownIssue.getBaselineVersion()); assertEquals(5, knownIssue.getBaselineScoreImpact()); diff --git a/backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java b/backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java new file mode 100644 index 000000000..7cc835fcf --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java @@ -0,0 +1,62 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.modtale.config.db.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ArtifactManifestReferencesTest { + private static class MemoryStore implements ArtifactManifestStore { + final Map> records = new HashMap<>(); int reads, writes; + public String put(Map entries) { writes++; String id = SecurityManifest.identity(entries); records.putIfAbsent(id, Map.copyOf(entries)); return id; } + public Map get(String id) { reads++; return records.get(id); } + } + @Test void referencesPreserveFullEvidenceAndPublicJsonWithoutDuplicatingProjectPayloads() throws Exception { + var store = new MemoryStore(); var original = ScanEvidenceFixtures.complete(true); + var writer = new SecurityEvidenceConverters.Write(store); var reader = new SecurityEvidenceConverters.Read(store); + Document stored = writer.convert(original.getSecurityEvidence()); + assertFalse(stored.containsKey("entryHashes")); assertTrue(stored.containsKey("manifestRef")); + var restored = reader.convert(stored); assertEquals(0, store.reads); + assertEquals(stored, writer.convert(restored)); assertEquals(0, store.reads); assertEquals(1, store.writes); + original.setSecurityEvidence(restored); + assertTrue(ArtifactClearancePolicy.complete(original)); assertEquals(1, store.reads); + String json = new ObjectMapper().writeValueAsString(restored); + assertTrue(json.contains("entryHashes")); assertFalse(json.contains("manifestRef")); + assertEquals(1, store.reads); + assertThrows(UnsupportedOperationException.class, () -> restored.entryHashes().put("other", "a".repeat(64))); + } + @Test void missingOrSubstitutedRecordsCannotGrantClearance() { + for (boolean missing : List.of(true, false)) { + var store = new MemoryStore(); var result = ScanEvidenceFixtures.complete(true); + Document stored = new SecurityEvidenceConverters.Write(store).convert(result.getSecurityEvidence()); + String id = stored.getString("manifestRef"); + if (missing) store.records.clear(); else store.records.put(id, Map.of("different.class", "f".repeat(64))); + result.setSecurityEvidence(new SecurityEvidenceConverters.Read(store).convert(stored)); + assertFalse(ArtifactClearancePolicy.complete(result)); assertFalse(ArtifactClearancePolicy.cleared(result)); + } + } + @Test void legacyEvidenceMigratesWhenWrittenAndAmbiguousReferencePayloadsAreRejected() { + var store = new MemoryStore(); var evidence = ScanEvidenceFixtures.complete(true).getSecurityEvidence(); + Document legacy = new SecurityEvidenceConverters.Write().convert(evidence); + var restored = new SecurityEvidenceConverters.Read(store).convert(legacy); + assertEquals(evidence.entryHashes(), restored.entryHashes()); + Document migrated = new SecurityEvidenceConverters.Write(store).convert(restored); + assertTrue(migrated.containsKey("manifestRef")); + migrated.put("entryHashes", List.of()); + assertThrows(Exception.class, () -> new SecurityEvidenceConverters.Read(store).convert(migrated)); + } + @Test void productionConfigurationCanConstructStoreAwareConvertersWithoutDatabaseMappingCycles() { + try (var context = new AnnotationConfigApplicationContext()) { + context.registerBean(MongoDatabaseFactory.class, () -> mock(MongoDatabaseFactory.class)); + context.register(MongoConfig.class); context.refresh(); + assertInstanceOf(MongoArtifactManifestStore.class, context.getBean(ArtifactManifestStore.class)); + assertNotNull(context.getBean(org.springframework.data.mongodb.core.convert.MongoCustomConversions.class)); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java new file mode 100644 index 000000000..9ffd5bdb2 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java @@ -0,0 +1,83 @@ +package net.modtale.service.security.scan; + +import java.util.*; +import net.modtale.model.project.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ArtifactReviewLineageTest { + private ProjectVersion source(String id) { + var v = new ProjectVersion(); v.setId(id); v.setVersionNumber(id); + v.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); v.setSecurityApprovalProjectId("p"); + var evidence = ScanEvidenceFixtures.complete(false).getSecurityEvidence(); + v.setHash(evidence.artifactSha256()); v.setApprovedSecurityEvidence(evidence); + v.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(v)); + v.setSecurityApprovedAt(System.currentTimeMillis() - 1000); v.setApprovedReviewOrigins(Map.of()); return v; + } + @Test void copiedApprovalRetainsEveryOriginalSource() { + var root = source("root"); var copy = source("copy"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root, copy)); + copy.setApprovedReviewOrigins(ArtifactReviewLineage.extend(project, root)); + var lineage = ArtifactReviewLineage.extend(project, copy); + assertEquals(Set.of("root", "copy"), lineage.keySet()); assertTrue(ArtifactReviewLineage.valid(project, lineage)); + root.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); + assertFalse(ArtifactReviewLineage.valid(project, lineage)); assertNull(ArtifactReviewLineage.extend(project, copy)); + } + @Test void removingAnAncestorCannotLaunderAStoredProof() { + var root = source("root"); var copy = source("copy"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root, copy)); + copy.setApprovedReviewOrigins(ArtifactReviewLineage.extend(project, root)); + var lineage = ArtifactReviewLineage.extend(project, copy); + assertFalse(ArtifactReviewLineage.valid(project, Map.of("copy", lineage.get("copy")))); + copy.setApprovedReviewOrigins(Map.of()); + assertFalse(ArtifactReviewLineage.valid(project, lineage)); + } + @Test void changedRevokedExpiredOrLegacySourceCannotBeReused() { + for (String change : List.of("hash", "context", "decision", "expiry", "legacy", "missing", "hold")) { + var root = source("root"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root)); + var lineage = ArtifactReviewLineage.extend(project, root); + switch (change) { + case "hash" -> root.setHash("f".repeat(64)); + case "context" -> root.setGameVersions(List.of("changed")); + case "decision" -> root.setFindingReviewHead("revocation"); + case "expiry" -> root.setSecurityApprovedAt(System.currentTimeMillis() - 31L * 86400000); + case "legacy" -> root.setApprovedReviewOrigins(null); + case "missing" -> project.setVersions(List.of()); + case "hold" -> root.setReplacementSecurityHold(UUID.randomUUID().toString()); + } + assertFalse(ArtifactReviewLineage.valid(project, lineage), change); + } + } + @Test void copyingVersionsToAnotherProjectDoesNotCopyApprovalAuthority() { + var root = source("root"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root)); + var origins = ArtifactReviewLineage.extend(project, root); + project.setId("copied-project"); + assertFalse(ArtifactReviewLineage.valid(project, origins)); + assertNull(ArtifactReviewLineage.extend(project, root)); + } + @Test void networkPayloadCannotInjectOriginsAndSnapshotsBindServerOrigins() throws Exception { + var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var result=json.readValue("{\"reusedReviewOrigins\":{\"source\":\""+"a".repeat(64)+"\"}}",ScanResult.class); + assertNull(result.getReusedReviewOrigins()); + var current=new ProjectVersion();current.setScanResult(result); + String before=net.modtale.service.admin.review.VersionReviewSnapshot.token(current); + result.setReusedReviewOrigins(Map.of("source","a".repeat(64))); + assertNotEquals(before,net.modtale.service.admin.review.VersionReviewSnapshot.token(current)); + assertFalse(json.writeValueAsString(result).contains("reusedReviewOrigins")); + } + @Test void lineageIsBoundedAndRejectsUnsafeStorageKeys() { + var entries=new HashMap(); + for(int i=0;i<32;i++) entries.put("version-"+i,"a".repeat(64)); + assertTrue(ArtifactReviewLineage.wellFormed(entries)); + entries.put("overflow","a".repeat(64)); assertFalse(ArtifactReviewLineage.wellFormed(entries)); + for(String id:List.of("version.with.dot","$operator","", "a".repeat(129))) + assertFalse(ArtifactReviewLineage.wellFormed(Map.of(id,"a".repeat(64)))); + assertFalse(ArtifactReviewLineage.wellFormed(null)); + } + @Test void freshIndependentClearanceDoesNotAcquireHistoricalDependencies() { + var root = source("root"); var current = new ProjectVersion(); current.setId("current"); + var project = new Project(); project.setId("p"); project.setVersions(List.of(root,current)); + var result = ScanEvidenceFixtures.complete(true); + new ArtifactReviewReuseService().annotate(project, "current", result); + assertNull(result.getReusedReviewVersion()); assertNull(result.getReusedReviewOrigins()); + assertTrue(ArtifactClearancePolicy.cleared(result)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java new file mode 100644 index 000000000..8f589a43b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java @@ -0,0 +1,109 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ArtifactReviewReuseServiceTest { + private final ArtifactReviewReuseService service = new ArtifactReviewReuseService(); + private Project project(ScanResult result) { + ProjectVersion prior = new ProjectVersion(); + prior.setId("prior"); prior.setVersionNumber("1.0"); + prior.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + prior.setApprovedSecurityEvidence(result.getSecurityEvidence()); + prior.setHash(result.getSecurityEvidence().artifactSha256()); + prior.setApprovedReviewOrigins(Map.of()); + prior.setSecurityApprovalProjectId("p"); + prior.setSecurityApprovedAt(System.currentTimeMillis() - 1000); + prior.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(prior)); + ProjectVersion current = new ProjectVersion(); current.setId("new"); current.setVersionNumber("1.1"); + Project project = new Project(); project.setId("p"); project.setVersions(List.of(prior, current)); return project; + } + @Test void freshAdverseEvidenceCannotBeHiddenByAnIdenticalHistoricalApproval() { + var result=ScanEvidenceFixtures.complete(false); + var project=project(result); + var evidence=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(),evidence.artifactSha256(), + evidence.contentSha256(),true,false,"NEW_SECURITY_EVIDENCE",evidence.entryHashes())); + service.annotate(project,"new",result); + assertNull(result.getReusedReviewVersion()); + result.setReusedReviewVersion("forged-stale-reuse"); + assertFalse(ArtifactClearancePolicy.cleared(result)); + } + @Test void recordedDecisionsPreventImplicitReuseUntilExplicitlyEvaluated() { + for (int index : List.of(0, 1)) { + var result = ScanEvidenceFixtures.complete(false); + var project = project(result); + project.getVersions().get(index).setFindingReviewHead("decision-history"); + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion()); + } + } + @Test void unchangedFullyInspectedArtifactReusesReview() { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + service.annotate(project, "new", result); + assertEquals("1.0", result.getReusedReviewVersion()); + assertEquals(project.getVersions().getFirst().getSecurityApprovedAt(), result.getReusedReviewApprovedAt()); + } + @Test void retainedReplacementBlockPreventsReuseAsSourceOrTarget() { + for(int index:List.of(0,1)) { + var result=ScanEvidenceFixtures.complete(false);var project=project(result); + project.getVersions().get(index).setReplacementSecurityHold(UUID.randomUUID().toString()); + service.annotate(project,"new",result);assertNull(result.getReusedReviewVersion()); + } + } + @Test void staleRejectedUnverifiedOrChangedArtifactsCannotReuse() { + for (String scenario : List.of("stale", "rejected", "unverified", "changed", "policy", "blocked", "incomplete")) { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + ProjectVersion prior = project.getVersions().getFirst(); + var evidence = result.getSecurityEvidence(); + switch (scenario) { + case "stale" -> prior.setSecurityApprovedAt(System.currentTimeMillis() - Duration.ofDays(31).toMillis()); + case "rejected" -> prior.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); + case "unverified" -> result.setArtifactVerified(false); + case "changed" -> prior.setApprovedSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), "c".repeat(64), true, false, "COMPLETED", Map.of("Mod.class", "d".repeat(64)))); + case "policy" -> prior.setApprovedSecurityEvidence(new ScanResult.SecurityEvidence("older", evidence.artifactSha256(), evidence.contentSha256(), true, false, "COMPLETED", evidence.entryHashes())); + case "blocked" -> result.setVerdict("BLOCK"); + case "incomplete" -> result.getSummary().setRecoverableErrors(1); + } + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion(), scenario); + } + } + @Test void changedContextOrSupplementalContentCannotReuse() { + for (String scenario : List.of("games", "dependency", "override", "missing-snapshot", "edited-prior")) { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + ProjectVersion current = project.getVersions().get(1); + switch (scenario) { + case "games" -> current.setGameVersions(List.of("changed-runtime")); + case "dependency" -> current.setDependencies(List.of(new ProjectDependency("dependency", "Dependency", "1.0"))); + case "override" -> current.setOverrideFileUrl("storage/supplement.zip"); + case "missing-snapshot" -> project.getVersions().getFirst().setApprovedSecurityContextSha256(null); + case "edited-prior" -> project.getVersions().getFirst().setGameVersions(List.of("edited-after-approval")); + } + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion(), scenario); + } + } + @Test void compactContentDigestIsSufficientWithoutRetainingEveryPriorEntry() { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + var evidence = result.getSecurityEvidence(); + project.getVersions().getFirst().setApprovedSecurityEvidence(new ScanResult.SecurityEvidence( + evidence.policyVersion(), evidence.artifactSha256(), evidence.contentSha256(), true, false, "COMPLETED", Map.of())); + service.annotate(project, "new", result); + assertEquals("1.0", result.getReusedReviewVersion()); + } + @Test void v2ApprovalDoesNotSupplyV3Evidence() { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + project.getVersions().getFirst().setApprovedSecurityEvidence(null); + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/DependencyArtifactVerifierTest.java b/backend/src/test/java/net/modtale/service/security/scan/DependencyArtifactVerifierTest.java new file mode 100644 index 000000000..4f27e435a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/DependencyArtifactVerifierTest.java @@ -0,0 +1,129 @@ +package net.modtale.service.security.scan; + +import org.junit.jupiter.api.Test; +import java.io.*; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static net.modtale.service.security.scan.DependencyReviewGraph.*; +import static net.modtale.service.security.scan.DependencyArtifactVerifier.State.*; +import static org.junit.jupiter.api.Assertions.*; + +class DependencyArtifactVerifierTest { + String hash(byte[] value)throws Exception {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));} + Snapshot node(String id,String path,byte[] bytes)throws Exception {return new Snapshot("p",id,id,path,hash(bytes),"b".repeat(64),null,false,List.of());} + Inventory inventory(Snapshot... nodes){return new Inventory(nodes[0].key(),List.of(nodes),List.of(),List.of(),"c".repeat(64),1);} + @Test void verifiesStoredBytesWithSharedFileDeduplication()throws Exception { + byte[] bytes={1,2,3};var calls=new AtomicInteger(); + var verifier=new DependencyArtifactVerifier(path->{calls.incrementAndGet();return new ByteArrayInputStream(bytes);},new Semaphore(2)); + var result=verifier.verify(inventory(node("a","file",bytes),node("b","file",bytes)),3,Duration.ofSeconds(1)); + assertTrue(result.matched());assertEquals(3,result.bytes());assertEquals(1,calls.get());assertEquals(hash(bytes),result.artifacts().getFirst().actualSha256()); + assertEquals("c".repeat(64),result.inventoryIdentity()); + } + @Test void changedBytesAndConflictingSharedIdentitiesCannotMatch()throws Exception { + byte[] bytes={1,2,3};var verifier=new DependencyArtifactVerifier(path->new ByteArrayInputStream(bytes),new Semaphore(2)); + var wrong=verifier.verify(inventory(node("a","file",new byte[]{4})),10,Duration.ofSeconds(1)); + assertEquals(MISMATCH,wrong.state());assertEquals(hash(bytes),wrong.artifacts().getFirst().actualSha256()); + var conflict=verifier.verify(inventory(node("a","file",bytes),node("b","file",new byte[]{4})),10,Duration.ofSeconds(1)); + assertEquals(MISMATCH,conflict.state()); + } + @Test void enforcesCumulativeByteBudgetAndAbortsOversizedS3Streams()throws Exception { + byte[] bytes={1,2,3};var streams=new ArrayList>(); + var verifier=new DependencyArtifactVerifier(path->{ + var stream=org.mockito.Mockito.spy(new software.amazon.awssdk.core.ResponseInputStream<>( + software.amazon.awssdk.services.s3.model.GetObjectResponse.builder().build(),new ByteArrayInputStream(bytes))); + streams.add(stream);return stream; + },new Semaphore(2)); + var result=verifier.verify(inventory(node("a","first",bytes),node("b","second",bytes)),5,Duration.ofSeconds(2)); + assertEquals(BYTE_LIMIT,result.state());assertEquals(6,result.bytes());assertNull(result.artifacts().getLast().actualSha256()); + for(var stream:streams){org.mockito.Mockito.verify(stream).abort();org.mockito.Mockito.verify(stream,org.mockito.Mockito.never()).close();} + } + @Test void rejectsUnresolvedInventoriesWithoutOpeningStorage()throws Exception { + var calls=new AtomicInteger();var verifier=new DependencyArtifactVerifier(path->{calls.incrementAndGet();throw new IOException();},new Semaphore(2)); + var root=node("a","file",new byte[]{1}); + var inventory=new Inventory(root.key(),List.of(root),List.of(),List.of(new Gap(root.key(),root.reference(),Reason.MISSING)),null,1); + assertEquals(UNRESOLVED,verifier.verify(inventory).state());assertEquals(0,calls.get()); + } + @Test void unavailableTruncatedAndNonProgressingStreamsDoNotVerify()throws Exception { + var input=inventory(node("a","file",new byte[]{1,2})); + var unavailable=new DependencyArtifactVerifier(path->{throw new IOException("Unavailable");},new Semaphore(2)); + assertEquals(UNAVAILABLE,unavailable.verify(input).state()); + var truncated=new DependencyArtifactVerifier(path->new ByteArrayInputStream(new byte[]{1}),new Semaphore(2)); + assertEquals(MISMATCH,truncated.verify(input).state()); + var zero=new DependencyArtifactVerifier(path->new InputStream(){public int read(){return 0;}public int read(byte[] b,int off,int len){return 0;}},new Semaphore(2)); + assertEquals(UNAVAILABLE,zero.verify(input).state()); + } + @Test void callerTimeoutDoesNotReleaseCapacityForAStillBlockedRead()throws Exception { + var release=new CountDownLatch(1);var closed=new CountDownLatch(1);var entered=new CountDownLatch(1);var capacity=new Semaphore(1); + var verifier=new DependencyArtifactVerifier(path->new InputStream(){ + public int read(){ + entered.countDown();boolean finished=false; + while(!finished)try{release.await();finished=true;}catch(InterruptedException ignored){} + return -1; + } + public void close(){closed.countDown();} + },capacity); + var input=inventory(node("a","file",new byte[0])); + try { + assertEquals(TIME_LIMIT,verifier.verify(input,10,Duration.ofMillis(250)).state()); + assertTrue(entered.await(1,TimeUnit.SECONDS));assertEquals(0,capacity.availablePermits()); + assertEquals(BUSY,verifier.verify(input,10,Duration.ofMillis(250)).state()); + } finally {release.countDown();} + assertTrue(closed.await(2,TimeUnit.SECONDS));assertTrue(capacity.tryAcquire(2,TimeUnit.SECONDS));capacity.release(); + } + @Test void lateStorageOpenIsClosedWithoutReadingAfterTimeout()throws Exception { + var release=new CountDownLatch(1);var closed=new CountDownLatch(1);var capacity=new Semaphore(1);var reads=new AtomicInteger(); + var verifier=new DependencyArtifactVerifier(path->{ + boolean finished=false;while(!finished)try{release.await();finished=true;}catch(InterruptedException ignored){} + return new ByteArrayInputStream(new byte[]{1}){ + public synchronized int read(byte[] b,int off,int len){reads.incrementAndGet();return super.read(b,off,len);} + public void close(){closed.countDown();} + }; + },capacity); + try {assertEquals(TIME_LIMIT,verifier.verify(inventory(node("a","file",new byte[]{1})),10,Duration.ofMillis(250)).state());} + finally {release.countDown();} + assertTrue(closed.await(2,TimeUnit.SECONDS));assertTrue(capacity.tryAcquire(2,TimeUnit.SECONDS));capacity.release();assertEquals(0,reads.get()); + } + @Test void realStorageSdkStreamsMatchThenRecoverAfterAStalledResponse() throws Exception { + byte[] bytes={1,2,3};var stall=new java.util.concurrent.atomic.AtomicBoolean(); + var entered=new CountDownLatch(1);var release=new CountDownLatch(1); + var server=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0); + server.setExecutor(java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()); + server.createContext("/",exchange->{ + try { + exchange.sendResponseHeaders(200,bytes.length); + if(stall.get()) {exchange.getResponseBody().write(bytes[0]);exchange.getResponseBody().flush();entered.countDown(); + try {release.await(5,TimeUnit.SECONDS);}catch(InterruptedException interrupted){Thread.currentThread().interrupt();} + exchange.getResponseBody().write(bytes,1,2); + } else exchange.getResponseBody().write(bytes); + } finally {exchange.close();} + });server.start(); + try(var s3=software.amazon.awssdk.services.s3.S3Client.builder() + .endpointOverride(java.net.URI.create("http://127.0.0.1:"+server.getAddress().getPort())) + .region(software.amazon.awssdk.regions.Region.US_EAST_1) + .serviceConfiguration(software.amazon.awssdk.services.s3.S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create( + software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("test-key","test-secret"))).build()) { + var storage=new net.modtale.service.storage.StorageService(s3,new net.modtale.config.properties.AppR2Properties("bucket","","","",""),null); + var capacity=new Semaphore(1);var verifier=new DependencyArtifactVerifier(storage::getStream,capacity); + var input=inventory(node("a","file",bytes)); + assertTrue(verifier.verify(input,10,Duration.ofSeconds(5)).matched()); + stall.set(true); + assertEquals(TIME_LIMIT,verifier.verify(input,10,Duration.ofMillis(500)).state()); + assertTrue(entered.await(2,TimeUnit.SECONDS));release.countDown(); + assertTrue(capacity.tryAcquire(3,TimeUnit.SECONDS));capacity.release();stall.set(false); + assertTrue(verifier.verify(input,10,Duration.ofSeconds(5)).matched()); + } finally {release.countDown();server.stop(0);} + } + @Test void largeInputIsReadInSmallChunksAndClosed()throws Exception { + byte[] bytes=new byte[1024*1024];new Random(123).nextBytes(bytes);var max=new AtomicInteger();var closed=new AtomicInteger(); + var verifier=new DependencyArtifactVerifier(path->new ByteArrayInputStream(bytes){ + public synchronized int read(byte[] b,int off,int len){max.accumulateAndGet(len,Math::max);return super.read(b,off,len);} + public void close(){closed.incrementAndGet();} + },new Semaphore(2)); + assertTrue(verifier.verify(inventory(node("a","file",bytes)),bytes.length,Duration.ofSeconds(2)).matched()); + assertTrue(max.get()<=64*1024);assertEquals(1,closed.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/DependencyReviewGraphTest.java b/backend/src/test/java/net/modtale/service/security/scan/DependencyReviewGraphTest.java new file mode 100644 index 000000000..446a936eb --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/DependencyReviewGraphTest.java @@ -0,0 +1,105 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.ProjectDependency; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static net.modtale.service.security.scan.DependencyReviewGraph.*; +import static org.junit.jupiter.api.Assertions.*; + +class DependencyReviewGraphTest { + private static final String HASH="a".repeat(64),CONTEXT="b".repeat(64); + private Snapshot node(String id,Dependency... dependencies) { + return new Snapshot(id,id+"-version","1.0","files/"+id,HASH,CONTEXT,null,false,List.of(dependencies)); + } + private Dependency dependency(String id) {return dependency(id,ProjectDependency.DependencyType.REQUIRED);} + private Dependency dependency(String id,ProjectDependency.DependencyType type) { + return new Dependency(ProjectDependency.Source.MODTALE,type,new Reference(id,"1.0")); + } + private Source source(Snapshot... snapshots) { + var values=new HashMap();for(var snapshot:snapshots)values.put(snapshot.reference(),snapshot); + return ref->{var snapshot=values.get(ref);return new Lookup(snapshot==null?State.MISSING:State.FOUND,snapshot);}; + } + private Inventory inspect(Snapshot root,Snapshot... nodes){return DependencyReviewGraph.inspect(root,source(nodes),Limits.defaults());} + private void reason(Inventory inventory,Reason reason) { + assertFalse(inventory.resolved());assertNull(inventory.identity());assertTrue(inventory.gaps().stream().anyMatch(g->g.reason()==reason),inventory.gaps().toString()); + } + @Test void diamondInventoryRetainsEveryDeclarationAndReadsSharedVersionsOnceBeforeRevalidation() { + var shared=node("shared");var left=node("left",dependency("shared"));var right=node("right",dependency("shared")); + var root=node("root",dependency("left"),dependency("right",ProjectDependency.DependencyType.OPTIONAL),dependency("shared",ProjectDependency.DependencyType.EMBEDDED)); + var graph=inspect(root,root,left,right,shared); + assertTrue(graph.resolved());assertEquals(4,graph.nodes().size());assertEquals(5,graph.edges().size());assertEquals(7,graph.reads()); + assertEquals(Set.of(ProjectDependency.DependencyType.REQUIRED,ProjectDependency.DependencyType.OPTIONAL,ProjectDependency.DependencyType.EMBEDDED), + new HashSet<>(graph.edges().stream().map(e->e.dependency().type()).toList())); + assertEquals(graph.identity(),inspect(root,shared,right,left,root).identity()); + assertThrows(UnsupportedOperationException.class,()->graph.nodes().clear()); + } + @Test void changedArtifactAndContextProduceDifferentInventoriesEvenWithSameVersionLabel() { + var child=node("child");var root=node("root",dependency("child"));var original=inspect(root,root,child); + var bytes=new Snapshot(child.projectId(),child.versionId(),child.versionNumber(),child.fileReference(),"c".repeat(64),CONTEXT,null,false,List.of()); + var context=new Snapshot(child.projectId(),child.versionId(),child.versionNumber(),child.fileReference(),HASH,"d".repeat(64),null,false,List.of()); + assertNotEquals(original.identity(),inspect(root,root,bytes).identity()); + assertNotEquals(original.identity(),inspect(root,root,context).identity()); + } + @Test void missingAmbiguousAndUnavailablePinsNeverReceiveAnIdentity() { + var root=node("root",dependency("missing")); + for(var state:List.of(State.MISSING,State.AMBIGUOUS,State.UNAVAILABLE)) { + var graph=DependencyReviewGraph.inspect(root,ref->ref.equals(root.reference())?new Lookup(State.FOUND,root):new Lookup(state,null),Limits.defaults()); + reason(graph,Reason.valueOf(state.name())); + } + reason(DependencyReviewGraph.inspect(root,ref->{throw new IllegalStateException("Unavailable");},Limits.defaults()),Reason.UNAVAILABLE); + } + @Test void externalReferencesAreRepresentedWithoutCallingTheirSource() { + var root=node("root",new Dependency(ProjectDependency.Source.WEBSITE,ProjectDependency.DependencyType.REQUIRED,new Reference("external","1.0"))); + var calls=new ArrayList();var graph=DependencyReviewGraph.inspect(root,ref->{calls.add(ref);return new Lookup(State.FOUND,root);},Limits.defaults()); + reason(graph,Reason.EXTERNAL);assertEquals(List.of(root.reference()),calls);assertNull(graph.edges().getFirst().target()); + } + @Test void cyclesAndEveryStructuralLimitAreExplicit() { + var root=node("root",dependency("child"));var child=node("child",dependency("root")); + reason(inspect(root,root,child),Reason.CYCLE); + reason(DependencyReviewGraph.inspect(root,source(root,child),new Limits(1,256,8,128)),Reason.NODE_LIMIT); + reason(DependencyReviewGraph.inspect(root,source(root,child),new Limits(64,256,0,128)),Reason.DEPTH_LIMIT); + var many=node("root",dependency("a"),dependency("b")); + reason(DependencyReviewGraph.inspect(many,source(many,node("a"),node("b")),new Limits(64,1,8,128)),Reason.EDGE_LIMIT); + var limited=DependencyReviewGraph.inspect(root,source(root,node("child")),new Limits(64,256,8,1)); + reason(limited,Reason.READ_LIMIT);assertEquals(1,limited.reads()); + } + @Test void sharedSubtreeCannotHideAPathBeyondTheDepthLimit() { + var leaf=node("leaf");var shared=node("shared",dependency("leaf")); + var detour=node("detour",dependency("shared")); + var root=node("root",dependency("shared"),dependency("detour")); + var graph=DependencyReviewGraph.inspect(root,source(root,shared,detour,leaf),new Limits(64,256,2,128)); + reason(graph,Reason.DEPTH_LIMIT); + assertEquals(4,graph.edges().size()); + assertTrue(DependencyReviewGraph.inspect(root,source(root,shared,detour,leaf),new Limits(64,256,3,128)).resolved()); + } + @Test void rootReplacementDuringInspectionInvalidatesTheInventory() { + var root=node("root"); + var replacement=new Snapshot("root","different-version","1.0","file",HASH,CONTEXT,null,false,List.of()); + reason(DependencyReviewGraph.inspect(root,ref->new Lookup(State.FOUND,replacement),Limits.defaults()),Reason.CHANGED); + } + @Test void identityMismatchAndMutationDuringInspectionCannotResolve() { + var root=node("root",dependency("child"));var child=node("child"); + reason(DependencyReviewGraph.inspect(root,ref->new Lookup(State.FOUND,root),Limits.defaults()),Reason.INVALID_IDENTITY); + var reads=new AtomicInteger(); + var graph=DependencyReviewGraph.inspect(root,ref->{ + if(ref.equals(root.reference()))return new Lookup(State.FOUND,root); + return reads.incrementAndGet()==1?new Lookup(State.FOUND,child):new Lookup(State.MISSING,null); + },Limits.defaults());reason(graph,Reason.CHANGED); + var wrongScan=new Snapshot("root","root-version","1.0","file",HASH,CONTEXT,new RecordedScan("e".repeat(64),HASH,"policy"),false,List.of()); + reason(inspect(wrongScan,wrongScan),Reason.INVALID_IDENTITY); + } + @Test void unresolvedContextAndSupplementalInputsRemainExplicit() { + var root=new Snapshot("root","v","1.0","file",HASH,null,null,true,List.of()); + var graph=inspect(root,root);reason(graph,Reason.UNRESOLVED_CONTEXT);reason(graph,Reason.SUPPLEMENTAL_CONTENT); + } + @Test void inputCollectionsAreCopiedAndMalformedIdentitiesRejected() { + var dependencies=new ArrayList();var root=new Snapshot("root","v","1.0","file",HASH,CONTEXT,null,false,dependencies); + dependencies.add(dependency("unexpected"));assertTrue(root.dependencies().isEmpty()); + assertThrows(IllegalArgumentException.class,()->new Reference("root","")); + assertThrows(IllegalArgumentException.class,()->new Reference("root\nother","1")); + assertThrows(IllegalArgumentException.class,()->new Limits(65,256,8,128)); + assertThrows(IllegalArgumentException.class,()->new Lookup(State.FOUND,null)); + assertThrows(IllegalArgumentException.class,()->new Snapshot("root","v","1","file","wrong",CONTEXT,null,false,List.of())); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/DependencyReviewSourceTest.java b/backend/src/test/java/net/modtale/service/security/scan/DependencyReviewSourceTest.java new file mode 100644 index 000000000..49083a543 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/DependencyReviewSourceTest.java @@ -0,0 +1,183 @@ +package net.modtale.service.security.scan; + +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoClient; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import static net.modtale.service.security.scan.DependencyReviewGraph.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class DependencyReviewSourceTest { + MongoClient client;MongoTemplate mongo; + @BeforeEach void setup() throws Exception { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"); + if(!Set.of("27029","27030").contains(port))throw new IllegalStateException("Unexpected test database port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=2000"); + var factory=new org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory(client,"dependency_inventory_test_"+UUID.randomUUID().toString().replace("-","")); + var conversions=new net.modtale.config.db.MongoConfig().mongoCustomConversions(new net.modtale.config.db.MongoArtifactManifestStore(factory)); + var context=new org.springframework.data.mongodb.core.mapping.MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new org.springframework.data.mongodb.core.convert.MappingMongoConverter(new org.springframework.data.mongodb.core.convert.DefaultDbRefResolver(factory),context); + converter.setCustomConversions(conversions);converter.afterPropertiesSet();mongo=new MongoTemplate(factory,converter); + } + @AfterEach void cleanup(){if(mongo!=null)mongo.getDb().drop();if(client!=null)client.close();} + Document version(String id,String label) { + return new Document("_id",id).append("versionNumber",label).append("fileUrl","files/"+id).append("hash","a".repeat(64)) + .append("gameVersions",List.of("1")).append("dependencies",List.of()); + } + void project(Object id,Document... versions){mongo.getCollection("projects").insertOne(new Document("_id",id).append("versions",List.of(versions)));} + @Test void duplicateBsonNamesCannotResolveEvenWhenRepeatedValuesAgree() { + for(boolean conflicting:List.of(false,true))for(String level:List.of("root","version","dependency","scan","evidence")) { + var buffer=new org.bson.io.BasicOutputBuffer(); + try(var writer=new org.bson.BsonBinaryWriter(buffer)) { + writer.writeStartDocument();writer.writeString("_id","duplicate-"+level+conflicting); + writer.writeString("title","same");if(level.equals("root"))writer.writeString("title",conflicting?"different":"same"); + writer.writeStartArray("versions");writer.writeStartDocument(); + writer.writeString("_id","v");writer.writeString("versionNumber","1");writer.writeString("fileUrl","file"); + writer.writeString("hash","a".repeat(64));if(level.equals("version"))writer.writeString("hash",(conflicting?"b":"a").repeat(64)); + writer.writeStartArray("dependencies"); + if(level.equals("dependency")) { + writer.writeStartDocument();writer.writeString("projectId","child");writer.writeString("versionNumber","1"); + writer.writeString("source","MODTALE");writer.writeString("source",conflicting?"WEBSITE":"MODTALE");writer.writeEndDocument(); + } + writer.writeEndArray();writer.writeStartDocument("scanResult"); + writer.writeString("verdict","REVIEW");if(level.equals("scan"))writer.writeString("verdict",conflicting?"CLEAN":"REVIEW"); + writer.writeStartDocument("securityEvidence");writer.writeString("artifactSha256","a".repeat(64)); + if(level.equals("evidence"))writer.writeString("artifactSha256",(conflicting?"b":"a").repeat(64)); + writer.writeString("contentSha256","b".repeat(64));writer.writeString("policyVersion","policy"); + writer.writeEndDocument();writer.writeEndDocument();writer.writeEndDocument();writer.writeEndArray();writer.writeEndDocument(); + } + mongo.getCollection("projects").withDocumentClass(org.bson.RawBsonDocument.class).insertOne(new org.bson.RawBsonDocument(buffer.toByteArray())); + assertEquals(State.AMBIGUOUS,new DependencyReviewSource(mongo).readRoot("duplicate-"+level+conflicting,"v").state(),level); + } + } + @Test void projectedRecordDepthAndMalformedVersionContainersFailClosed() { + Document nested=new Document("value","leaf");for(int i=0;i<70;i++)nested=new Document("child",nested); + project("deep",version("v","1").append("metadata",nested)); + assertEquals(State.UNAVAILABLE,new DependencyReviewSource(mongo).readRoot("deep","v").state()); + mongo.getCollection("projects").insertOne(new Document("_id","malformed").append("versions",List.of("not a version"))); + assertEquals(State.UNAVAILABLE,new DependencyReviewSource(mongo).readRoot("malformed","v").state()); + project("normal",version("v","1").append("metadata",new Document("outer",List.of(new Document("value","ok"))))); + assertEquals(State.FOUND,new DependencyReviewSource(mongo).readRoot("normal","v").state()); + } + @Test void realGraphResolvesPinnedLabelsAndRevalidatesEveryRecord() { + var child=version("child-id","Release-A");project("child",child); + var root=version("root-id","1").append("dependencies",List.of(new Document("projectId","child").append("versionNumber","release-a"))); + project("root",root); + var source=new DependencyReviewSource(mongo);var selected=source.readRoot("root","root-id"); + assertEquals(State.FOUND,selected.state()); + var graph=DependencyReviewGraph.inspect(selected.snapshot(),source,Limits.defaults()); + assertTrue(graph.resolved(),graph.gaps().toString());assertEquals(2,graph.nodes().size()); + assertEquals("child-id",graph.edges().getFirst().target().versionId()); + } + @Test void applicationStoredVersionsPreserveContextAndRecordedScanIdentity() { + var version=new net.modtale.model.project.ProjectVersion();version.setId("v");version.setVersionNumber("1"); + version.setFileUrl("files/v");version.setGameVersions(List.of("1"));version.setManifestId("example");version.setManifestVersion("1"); + var result=ScanEvidenceFixtures.complete(false);version.setHash(result.getSecurityEvidence().artifactSha256());version.setScanResult(result); + var project=new net.modtale.model.project.Project();project.setId("project");project.setVersions(List.of(version));mongo.save(project); + var lookup=new DependencyReviewSource(mongo).readRoot("project","v");assertEquals(State.FOUND,lookup.state()); + assertEquals(ArtifactReviewContext.fingerprint(version),lookup.snapshot().contextSha256()); + assertEquals(result.getSecurityEvidence().contentSha256(),lookup.snapshot().recordedScan().contentSha256()); + assertEquals(result.getSecurityEvidence().policyVersion(),lookup.snapshot().recordedScan().policyVersion()); + } + @Test void externalAndSupplementalInputsRemainVisibleWithoutClearance() { + var root=version("v","1").append("overrideFileUrl","overrides.zip").append("dependencies",List.of( + new Document("projectId","external:example").append("versionNumber","1").append("source","WEBSITE").append("dependencyType","OPTIONAL"))); + project("root",root);var source=new DependencyReviewSource(mongo);var selected=source.readRoot("root","v"); + assertEquals(State.FOUND,selected.state());var graph=DependencyReviewGraph.inspect(selected.snapshot(),source,Limits.defaults()); + assertFalse(graph.resolved()); + assertTrue(graph.gaps().stream().anyMatch(g->g.reason()==Reason.EXTERNAL)); + assertTrue(graph.gaps().stream().anyMatch(g->g.reason()==Reason.SUPPLEMENTAL_CONTENT)); + } + @Test void duplicatePinsDuplicateIdsAndMixedProjectStorageIdentitiesAreAmbiguous() { + project("labels",version("one","V1"),version("two","v1")); + assertEquals(State.AMBIGUOUS,new DependencyReviewSource(mongo).read(new Reference("labels","v1")).state()); + project("ids",version("same","1"),version("same","2")); + assertEquals(State.AMBIGUOUS,new DependencyReviewSource(mongo).readRoot("ids","same").state()); + String id=new ObjectId().toHexString();project(id,version("v","1"));project(new ObjectId(id),version("v","1")); + assertEquals(State.AMBIGUOUS,new DependencyReviewSource(mongo).read(new Reference(id,"1")).state()); + } + @Test void objectIdProjectsAndUnicodeLabelsUseActualJavaPinSemantics() { + var id=new ObjectId();project(id,version("v","\u212aelvin")); + assertEquals(State.FOUND,new DependencyReviewSource(mongo).read(new Reference(id.toHexString(),"kelvin")).state()); + assertEquals(State.MISSING,new DependencyReviewSource(mongo).read(new Reference(id.toHexString(),"other")).state()); + } + @Test void oversizedVersionAndOversizedIndexNeverResolve() { + project("big",version("v","1").append("payload","x".repeat(270000))); + assertEquals(State.UNAVAILABLE,new DependencyReviewSource(mongo).readRoot("big","v").state()); + var versions=new ArrayList();for(int i=0;i<4097;i++)versions.add(version("v"+i,"v"+i)); + project("many",versions.toArray(Document[]::new)); + assertEquals(State.UNAVAILABLE,new DependencyReviewSource(mongo).readRoot("many","v0").state()); + } + @Test void malformedRecordsCannotAcquireDefaultIdentitiesOrContexts() { + for(String field:List.of("_id","versionNumber","hash","fileUrl")) { + var v=version("v","1");v.remove(field);String id="missing-"+field;project(id,v); + assertNotEquals(State.FOUND,new DependencyReviewSource(mongo).read(new Reference(id,"1")).state()); + } + project("bad",version("v","1").append("dependencies",List.of(new Document("projectId","child")))); + assertEquals(State.UNAVAILABLE,new DependencyReviewSource(mongo).readRoot("bad","v").state()); + } + @Test void sharedDeadlineExpiresBeforeFurtherDatabaseQueries() { + project("root",version("v","1"));var clock=new AtomicLong();var source=new DependencyReviewSource(mongo,clock::get,1_000_000_000L); + assertEquals(State.FOUND,source.readRoot("root","v").state());clock.set(1_000_000_000L); + assertEquals(State.UNAVAILABLE,source.readRoot("root","v").state()); + } + @Test void versionReorderingBetweenIndexAndRecordReadsFailsClosed() { + project("root",version("first","1"),version("second","2")); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + var source=new DependencyReviewSource(mongo,()->{ + if(calls.incrementAndGet()==3)mongo.getCollection("projects").updateOne(new Document("_id","root"), + new Document("$set",new Document("versions",List.of(version("second","2"),version("first","1"))))); + return 0; + },1_000_000_000L); + assertEquals(State.UNAVAILABLE,source.readRoot("root","first").state()); + } + @Test void byteEndpointRevalidatesRealDatabaseDependenciesAfterReadingStorage() throws Exception { + byte[] bytes={1,2,3};String hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + project("root",version("v","1").append("hash",hash).append("dependencies",List.of(new Document("projectId","child").append("versionNumber","1")))); + project("child",version("child-v","1").append("hash",hash)); + mongo.save(mongo.findById("root",net.modtale.model.project.Project.class)); + var projects=org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class); + org.mockito.Mockito.when(projects.getRawProjectById("root")).thenAnswer(i->mongo.findById("root",net.modtale.model.project.Project.class)); + var storage=org.mockito.Mockito.mock(net.modtale.service.storage.StorageService.class); + org.mockito.Mockito.when(storage.getStream(org.mockito.ArgumentMatchers.anyString())).thenAnswer(i->new java.io.ByteArrayInputStream(bytes)); + var controller=new net.modtale.controller.admin.DependencyInspectionController(projects,mongo,storage); + String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(projects.getRawProjectById("root")); + String identity=controller.inspect("root","v",token).getBody().inventory().identity(); + var verified=controller.verifyBytes("root","v",identity,token).getBody(); + assertTrue(verified.verification().matched());assertEquals(6,verified.verification().bytes()); + org.mockito.Mockito.when(storage.getStream("files/child-v")).thenAnswer(i->new java.io.ByteArrayInputStream(bytes){ + public void close(){mongo.getCollection("projects").updateOne(new Document("_id","child"),new Document("$set",new Document("versions.0.hash","d".repeat(64))));} + }); + assertEquals(409,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.verifyBytes("root","v",identity,token)).getStatusCode().value()); + } + @Test void controllerComposesRealDatabaseGraphWithTheOpenedReviewSnapshot() { + project("root",version("v","1").append("dependencies",List.of(new Document("projectId","child").append("versionNumber","1")))); + project("child",version("child-v","1")); + mongo.save(mongo.findById("root",net.modtale.model.project.Project.class)); + var projects=org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class); + org.mockito.Mockito.when(projects.getRawProjectById("root")).thenAnswer(i->mongo.findById("root",net.modtale.model.project.Project.class)); + var controller=new net.modtale.controller.admin.DependencyInspectionController(projects,mongo,org.mockito.Mockito.mock(net.modtale.service.storage.StorageService.class)); + String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(projects.getRawProjectById("root")); + var response=controller.inspect("root","v",token).getBody(); + assertFalse(response.artifactBytesVerified());assertEquals(2,response.inventory().nodes().size());assertTrue(response.inventory().resolved()); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.when(projects.getRawProjectById("root")).thenAnswer(i->{ + if(calls.incrementAndGet()==2)mongo.getCollection("projects").updateOne(new Document("_id","root"), + new Document("$set",new Document("versions.0.findingReviewHead","changed"))); + return mongo.findById("root",net.modtale.model.project.Project.class); + }); + assertEquals(409,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.inspect("root","v",token)).getStatusCode().value()); + } + @Test void graphRejectsRealStoredReplacementAfterRootCapture() { + project("root",version("v","1"));var source=new DependencyReviewSource(mongo);var root=source.readRoot("root","v").snapshot(); + mongo.getCollection("projects").updateOne(new Document("_id","root"),new Document("$set",new Document("versions.0.hash","b".repeat(64)))); + var graph=DependencyReviewGraph.inspect(root,source,Limits.defaults()); + assertFalse(graph.resolved());assertTrue(graph.gaps().stream().anyMatch(g->g.reason()==Reason.CHANGED)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationActivatorTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationActivatorTest.java new file mode 100644 index 000000000..8170940d9 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationActivatorTest.java @@ -0,0 +1,86 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.ClientSession; +import net.modtale.service.admin.review.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationActivatorTest { + ProjectMutationAdmissionPreparationTest base=new ProjectMutationAdmissionPreparationTest();ProjectMutationAdmissionPreparation.Prepared decision;ReviewRepairJournal journal; + @BeforeEach void setup()throws Exception{base.setup();base.base.check();decision=base.service.prepare(base.request(false),()->true);journal=new ReviewRepairJournal(f().mongo,base.base.base.base.base.archive);} + @AfterEach void cleanup(){base.cleanup();} + RemoteReviewStepIntegrationTest f(){return base.base.base.base.base.fixture;} + ProjectMutationActivator activator(){return activator(f().mongo);} + ProjectMutationActivator activator(MongoTemplate mongo){return new ProjectMutationActivator(mongo,base.base.budget,base.service,base.base.base.base.base.archive,journal);} + Document version(){return f().mongo.getCollection("projects").find().first().getList("versions",Document.class).get(1);} + ProjectMutationActivator.Result activate(){return activator().activate(decision,"moderator",()->true);} + @Test void activationIsDiscoverableAndRetainedWithoutAnEphemeralEnqueue(){ + var original=version();var result=activate();assertEquals("APPLIED",result.state());assertEquals("REMOTE_REVIEW",version().get("scanResult",Document.class).get("scanState")); + assertEquals(original.get("versionMutation"),version().get("versionMutation"));assertEquals(original.get("replacementSecurityHold"),version().get("replacementSecurityHold")); + var candidates=new RemoteReviewDiscovery(f().mongo).page(null,64).candidates();assertEquals(1,candidates.size());assertEquals(decision.binding().requestId(),candidates.getFirst().requestId()); + assertEquals(result,activate());assertEquals(1,f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments());assertEquals(0,f().posts.get()); + assertNotNull(new RemoteReviewPollStore(f().mongo).claim(decision.binding(),30000)); + } + @ParameterizedTest @ValueSource(strings={"DRAFT","DELETED","ARCHIVED"}) + void withdrawnLifecycleCannotActivate(String status){ + f().mongo.getCollection("projects").updateOne(new Document("_id",base.base.project),new Document("$set",new Document("status",status))); + assertEquals("NOT_APPLIED",activate().state());assertEquals("MUTATION_HELD",version().get("scanResult",Document.class).get("scanState"));assertEquals(0,f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments()); + } + @Test void heldBytesAndConfigurationChangesPreventAnExecutionClaim(){ + base.config="d".repeat(64);assertThrows(RuntimeException.class,this::activate);base.config="c".repeat(64); + f().mongo.getCollection("projects").updateOne(new Document("_id",base.base.project),new Document("$set",new Document("versions.1.changelog","changed"))); + assertThrows(RuntimeException.class,this::activate);assertNull(f().mongo.getCollection(ReviewRepairJournal.COLLECTION).find(new Document("_id",decision.id())).first()); + } + @Test void revocationAfterAdmissionWriteRollsBackVersionAndAdmission(){ + var mongo=spy(f().mongo);var admissions=spy(mongo.getCollection(ProjectMutationActivator.ADMISSIONS));doReturn(admissions).when(mongo).getCollection(ProjectMutationActivator.ADMISSIONS); + var allowed=new AtomicBoolean(true);doAnswer(call->{call.callRealMethod();allowed.set(false);return null;}).when(admissions).insertOne(any(ClientSession.class),any(Document.class)); + var before=version();assertThrows(SecurityException.class,()->activator(mongo).activate(decision,"moderator",allowed::get));assertEquals(before,version());assertEquals(0,f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments()); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void lostCommitReplyRecoversWithoutRepeatingActivation(boolean conflictLabel){ + var mongo=spy(f().mongo);var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(mongo).getMongoDatabaseFactory(); + doAnswer(call->{var session=spy((ClientSession)call.callRealMethod());doAnswer(commit->{commit.callRealMethod();var failure=new MongoException(conflictLabel?112:0,"lost commit reply");if(conflictLabel)failure.addLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL);throw failure;}).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=activator(mongo).activate(decision,"moderator",()->true);assertEquals("APPLIED",result.state());assertEquals(result,activate());assertEquals(1,f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments());verify(factory,times(1)).getSession(any(ClientSessionOptions.class)); + } + @Test void collisionCannotLeaveVersionActivatedWithoutItsOwnAdmission(){ + f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).insertOne(new Document("_id",decision.binding().requestId()).append("decisionId","other"));var before=version(); + assertThrows(IllegalStateException.class,this::activate);assertEquals(before,version());assertThrows(IllegalStateException.class,this::activate); + } + @Test void admissionCollisionAfterClaimRollsBackTheVersion() { + var raced=spy(journal);doAnswer(call->{var claim=call.callRealMethod();f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).insertOne(new Document("_id",decision.binding().requestId()).append("decisionId","competing"));return claim;}) + .when(raced).claim(any(),anyString(),any(),any()); + var before=version();var activation=new ProjectMutationActivator(f().mongo,base.base.budget,base.service,base.base.base.base.base.archive,raced); + assertEquals("UNKNOWN",activation.activate(decision,"moderator",()->true).state());assertEquals(before,version()); + } + @Test void competingDecisionsCannotActivateTheSameHeldRequestTwice(){ + var request=base.request(false);var other=base.service.prepare(new ProjectMutationAdmissionPreparation.Request(UUID.randomUUID().toString(),request.projectId(),request.versionIndex(),request.versionId(),request.heldSha256(),request.mutationId(),request.actor(),request.observations(),false),()->true); + activate();assertThrows(RuntimeException.class,()->activator().activate(other,"moderator",()->true));assertEquals(1,f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments()); + } + @Test void receiptAuthenticatesAdmissionAfterVisibleProjectDeletion(){ + var result=activate();f().mongo.getCollection("projects").deleteMany(new Document());assertEquals(result,activator().receipt(decision,"moderator",()->true)); + f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).updateOne(new Document("_id",decision.binding().requestId()),new Document("$set",new Document("afterSha256","0".repeat(64)))); + assertThrows(IllegalStateException.class,()->activator().receipt(decision,"moderator",()->true)); + } + @Test void existingWorkerDeliversAndCompletesTheFixedRequestAfterRestart(){ + assertEquals("APPLIED",activate().state());var f=f();f.server.removeContext("/api/v1/review-jobs");f.binding=decision.binding();f.job=UUID.randomUUID().toString();var created=new AtomicBoolean(); + f.route(e->{if(e.getRequestMethod().equals("POST")){created.set(true);f.reply(e,202,"QUEUED");}else f.reply(e,created.get()?200:404,"COMPLETED");}); + var bootstrap=new RemoteReviewBootstrap(new RemoteReviewPersistence(f.mongo),f.client,f.step); + assertEquals("RECORDED",bootstrap.advance(f.project,"new-upload",f.binding.attempt(),f.binding.requestId()).state()); + f.mongo.getCollection("projects").updateOne(new Document("_id",base.base.project),new Document("$set",new Document("versions.1.scanResult.remotePoll.nextPollAt",new Date(0)).append("versions.1.scanResult.remotePoll.leaseUntil",new Date(0)))); + var restarted=new RemoteReviewBootstrap(new RemoteReviewPersistence(f.mongo),f.client,new RemoteReviewStep(new RemoteReviewPollStore(f.mongo),f.client,f.storage,f.completion)); + assertEquals("APPLIED",restarted.advance(f.project,"new-upload",f.binding.attempt(),f.binding.requestId()).state()); + var approved=f.mongo.findById(f.project,Project.class).getVersions().get(1);assertEquals(ProjectVersion.ReviewStatus.SCHEDULED,approved.getReviewStatus());assertNotNull(approved.getScheduledPublishDate()); + assertEquals(f.binding.withJobId(f.job),approved.getScanResult().getRemoteReview());assertNotNull(approved.getScanResult().getSecurityEvidence());assertEquals(f.binding.requestId(),approved.getVersionMutation().requestId());assertEquals(1,f.posts.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionAttemptsTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionAttemptsTest.java new file mode 100644 index 000000000..a2eb21a25 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionAttemptsTest.java @@ -0,0 +1,89 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationAdmissionAttemptsTest { + ProjectMutationJobAccountingTest base=new ProjectMutationJobAccountingTest();ProjectMutationAdmissionAttempts attempts;ProjectMutationAdmissionAttempts.Scope scope; + @BeforeEach void setup()throws Exception{base.setup();var v=base.base.root().getList("versions",Document.class).get(1);var scan=v.get("scanResult",Document.class); + scope=new ProjectMutationAdmissionAttempts.Scope(base.project,"new-upload",base.prepared.id(),scan.getString("scanRequestId"),scan.getInteger("scanAttempt"));attempts=create(mongo(),base.budget);} + MongoTemplate mongo(){return base.base.base.base.fixture.mongo;} + ProjectMutationAdmissionAttempts create(MongoTemplate mongo,ReviewRepairWorkflow budget){return new ProjectMutationAdmissionAttempts(mongo,budget,2,200);} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationAdmissionAttempts.Claim begin(){return attempts.begin(scope,"a".repeat(64),()->true);} + @Test void runningAttemptSurvivesRestartWithoutBeingReclaimed(){ + var claim=begin();assertNotNull(claim);var restarted=create(mongo(),base.budget);assertEquals(claim,restarted.status(scope,()->true).current());assertNull(restarted.begin(scope,"b".repeat(64),()->true)); + mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION).updateOne(new Document("_id",scope.requestId()),new Document("$set",new Document("attempts.0.startedAt",new Date(1)))); + assertNull(begin());assertEquals("RUNNING",attempts.status(scope,()->true).state()); + } + @Test void completedWaitingAttemptGetsNewIdentityOnlyAfterCooldown()throws Exception { + var first=begin();var waiting=attempts.finish(first,ProjectMutationAdmissionAttempts.Outcome.WAITING,()->true);assertEquals("WAITING",waiting.state());assertNull(begin()); + Thread.sleep(250);var second=attempts.begin(scope,"b".repeat(64),()->true);assertNotNull(second);assertEquals(2,second.sequence());assertNotEquals(first.decisionId(),second.decisionId()); + assertThrows(IllegalStateException.class,()->attempts.finish(first,ProjectMutationAdmissionAttempts.Outcome.ADMITTED,()->true)); + var limit=attempts.finish(second,ProjectMutationAdmissionAttempts.Outcome.WAITING,()->true);assertEquals("ATTENTION",limit.state());Thread.sleep(250);assertNull(begin()); + assertEquals(2,mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION).find().first().getList("attempts",Document.class).size()); + } + @Test void verifiedProgressCanContinuePromptlyButStillConsumesAttemptCapacity()throws Exception{ + var ledger=new ProjectMutationAdmissionAttempts(mongo(),base.budget,2,60000);var first=ledger.begin(scope,"a".repeat(64),()->true); + var yielded=ledger.finish(first,ProjectMutationAdmissionAttempts.Outcome.YIELDED,()->true);assertEquals("YIELDED",yielded.state()); + var stored=mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION).find().first();long finished=stored.getList("attempts",Document.class).getFirst().getDate("finishedAt").getTime();assertEquals(100,yielded.nextAttemptAt()-finished); + assertEquals(yielded,ledger.finish(first,ProjectMutationAdmissionAttempts.Outcome.YIELDED,()->true));Thread.sleep(150); + var restarted=new ProjectMutationAdmissionAttempts(mongo(),base.budget,2,60000);var second=restarted.begin(scope,"a".repeat(64),()->true);assertNotNull(second);assertEquals(2,second.sequence());assertNotEquals(first.token(),second.token()); + var capped=restarted.finish(second,ProjectMutationAdmissionAttempts.Outcome.YIELDED,()->true);assertEquals("ATTENTION",capped.state());assertEquals(ProjectMutationAdmissionAttempts.Outcome.YIELDED,capped.outcome());Thread.sleep(150);assertNull(restarted.begin(scope,"a".repeat(64),()->true)); + } + @Test void yieldingNeverShortensTheNextUnfinishedJobCooldown()throws Exception{ + var ledger=new ProjectMutationAdmissionAttempts(mongo(),base.budget,3,60000);var first=ledger.begin(scope,"a".repeat(64),()->true);ledger.finish(first,ProjectMutationAdmissionAttempts.Outcome.YIELDED,()->true);Thread.sleep(150); + var second=ledger.begin(scope,"a".repeat(64),()->true);assertNotNull(second);var waiting=ledger.finish(second,ProjectMutationAdmissionAttempts.Outcome.WAITING,()->true); + var stored=mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION).find().first();long finished=stored.getList("attempts",Document.class).getLast().getDate("finishedAt").getTime();assertEquals(60000,waiting.nextAttemptAt()-finished);assertNull(ledger.begin(scope,"a".repeat(64),()->true)); + } + @Test void finishedAttemptIsIdempotentButCannotChangeOutcome(){ + var claim=begin();var done=attempts.finish(claim,ProjectMutationAdmissionAttempts.Outcome.ADMITTED,()->true); + assertEquals(done,attempts.finish(claim,ProjectMutationAdmissionAttempts.Outcome.ADMITTED,()->true));assertNull(begin()); + assertThrows(IllegalStateException.class,()->attempts.finish(claim,ProjectMutationAdmissionAttempts.Outcome.WAITING,()->true)); + } + @Test void scopeCannotBeRetargetedEvenToLookalikeProjectId(){ + begin();var other=new ProjectMutationAdmissionAttempts.Scope(scope.projectId().toString(),scope.versionId(),scope.mutationId(),scope.requestId(),scope.scanAttempt()); + assertThrows(IllegalStateException.class,()->attempts.begin(other,"a".repeat(64),()->true));assertThrows(SecurityException.class,()->attempts.status(scope,()->false)); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void independentWorkersCannotClaimTheSameGeneration(boolean yielded)throws Exception { + if(yielded){attempts.finish(begin(),ProjectMutationAdmissionAttempts.Outcome.YIELDED,()->true);Thread.sleep(150);} + try(var budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1);var workers=Executors.newFixedThreadPool(2)) { + var second=create(mongo(),budget);var start=new CountDownLatch(1); + var a=workers.submit(()->{start.await();return begin();});var b=workers.submit(()->{start.await();return second.begin(scope,"b".repeat(64),()->true);});start.countDown(); + var claims=Arrays.asList(a.get(10,TimeUnit.SECONDS),b.get(10,TimeUnit.SECONDS));assertEquals(1,claims.stream().filter(Objects::nonNull).count());assertEquals(yielded?2:1,attempts.status(scope,()->true).attempts()); + } + } + @Test void alteredRetainedAttemptIsNotReinterpreted(){ + begin();mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION).updateOne(new Document("_id",scope.requestId()),new Document("$set",new Document("attempts.0.decisionId",UUID.randomUUID().toString()))); + assertThrows(IllegalStateException.class,()->attempts.status(scope,()->true));assertThrows(IllegalStateException.class,this::begin); + } + MongoTemplate wrapped(MongoCollection collection){ + doReturn(collection).when(collection).withReadPreference(any());doReturn(collection).when(collection).withReadConcern(any());doReturn(collection).when(collection).withWriteConcern(any());doReturn(collection).when(collection).withTimeout(anyLong(),any()); + var mongo=spy(mongo());doReturn(collection).when(mongo).getCollection(ProjectMutationAdmissionAttempts.COLLECTION);return mongo; + } + @Test void lostClaimReplyReturnsOnlyTheRetainedClaim(){ + var collection=spy(mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION));var wrapped=wrapped(collection); + doAnswer(call->{call.callRealMethod();throw new MongoException("lost claim reply");}).when(collection).findOneAndUpdate(any(Bson.class),anyList(),any(FindOneAndUpdateOptions.class)); + var claim=create(wrapped,base.budget).begin(scope,"a".repeat(64),()->true);assertNotNull(claim);assertEquals(claim,attempts.status(scope,()->true).current());assertNull(begin()); + } + @Test void lostFinishReplyDoesNotCreateAnotherAttempt(){ + var claim=begin();var collection=spy(mongo().getCollection(ProjectMutationAdmissionAttempts.COLLECTION));var wrapped=wrapped(collection); + doAnswer(call->{call.callRealMethod();throw new MongoException("lost finish reply");}).when(collection).updateOne(any(Bson.class),anyList(),any(UpdateOptions.class)); + var result=create(wrapped,base.budget).finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,()->true);assertEquals("ATTENTION",result.state());assertEquals(1,result.attempts());assertNull(begin()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionPreparationTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionPreparationTest.java new file mode 100644 index 000000000..be829bb04 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionPreparationTest.java @@ -0,0 +1,103 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationAdmissionPreparationTest { + ProjectMutationJobAccountingTest base=new ProjectMutationJobAccountingTest();ProjectMutationPriorWorkReader prior;ProjectMutationAdmissionPreparation service; + String id=UUID.randomUUID().toString(),config="c".repeat(64),state="COMPLETED",workState="COMPLETED";int status=200; + Clock clock=Clock.fixed(Instant.now(),ZoneOffset.UTC); + @BeforeEach void setup()throws Exception { + base.setup();var f=base.base.base.base.fixture; + var old=new RawBsonDocument(base.base.base.base.archive.load(base.prepared.beforeArchiveId()).versionBytes()).decode(new DocumentCodec()); + f.mongo.getCollection("projects").replaceOne(new Document("_id",base.project),old); + var request=base.base.base.request();var next=new RawBsonDocument(request.proposedProject()).decode(new DocumentCodec()); + var upload=next.getList("versions",Document.class).get(1);upload.put("fileUrl",f.binding.filePath());upload.put("hash",f.binding.artifactSha256()); + base.prepared=base.base.base.service.prepare(new ProjectMutationPreparation.Request(request.id(),request.projectId(),request.expectedSha256(),request.actor(),request.mutation(),VersionMutationPreparationTest.bytes(next)),()->true); + assertEquals("APPLIED",base.base.executor().apply(base.prepared,"owner",()->true).state()); + prior=new ProjectMutationPriorWorkReader(f.mongo,base.budget,base.history);service=create(base.base.base.base.archive,clock); + f.route(e->{if(e.getRequestURI().getPath().endsWith("/configuration")){ + byte[] bytes=f.mapper.writeValueAsBytes(Map.of("policyVersion",f.binding.policyVersion(),"reviewConfigSha256",config));e.sendResponseHeaders(200,bytes.length);e.getResponseBody().write(bytes); + }else f.reply(e,status,state,workState);}); + } + ProjectMutationAdmissionPreparation create(ReviewSnapshotArchive archive,Clock time){var f=base.base.base.base.fixture;return new ProjectMutationAdmissionPreparation(f.mongo,base.budget,archive,base.history,prior,base.accounting,f.client,time,60000);} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationAdmissionPreparation.Request request(boolean ack){ + var capture=new RawReviewSnapshotReader(base.base.base.base.fixture.mongo).capture(base.project,1,"new-upload"); + return new ProjectMutationAdmissionPreparation.Request(id,base.project,1,"new-upload",capture.sha256(),base.prepared.id(),"moderator",Map.of(base.prepared.id()+"/v",base.id),ack); + } + @Test void signedDecisionBindsFixedHeldIdentityWithoutActivatingIt(){ + base.check();var before=base.base.root();var request=request(false);var decision=service.prepare(request,()->true); + assertEquals("PRIOR_WORK_ACCOUNTED",decision.rule());assertNull(decision.binding().jobId());assertEquals(base.base.base.base.fixture.origin,decision.binding().origin()); + assertEquals(before.getList("versions",Document.class).get(1).get("scanResult",Document.class).get("scanRequestId"),decision.binding().requestId()); + assertEquals(decision,service.prepare(request,()->true));assertEquals(decision,service.recover(id,"moderator",()->true)); + assertEquals(before,base.base.root());assertEquals(0,base.base.base.base.fixture.posts.get()); + } + @ParameterizedTest @ValueSource(strings={"RUNNING","QUEUED","CANCELLED","EXPIRED","HELD"}) + void nonCompletedOriginalRequiresExplicitDuplicateWorkAcknowledgment(String original){ + state=original;workState=null;base.check();assertThrows(IllegalStateException.class,()->service.prepare(request(false),()->true)); + assertEquals("ACKNOWLEDGED_UNCERTAINTY",service.prepare(request(true),()->true).rule()); + } + @Test void completedJobWithUnfinishedWorkerStillRequiresAcknowledgment(){workState="RUNNING";base.check();assertThrows(IllegalStateException.class,()->service.prepare(request(false),()->true));assertEquals("ACKNOWLEDGED_UNCERTAINTY",service.prepare(request(true),()->true).rule());} + @Test void missingObservationCannotBeAcknowledgedAway(){assertThrows(RuntimeException.class,()->service.prepare(request(true),()->true));} + @Test void activeObservationCannotBeAcknowledgedAway(){ + base.check();base.base.base.base.fixture.mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).updateOne(new Document("_id",base.id),new Document("$set",new Document("state","READING")).append("$unset",new Document("observation",1).append("receivedAt",1))); + assertThrows(RuntimeException.class,()->service.prepare(request(true),()->true)); + } + @Test void extraObservationAndDifferentActorCannotReuseDecision(){ + base.check();var request=request(false);service.prepare(request,()->true);assertThrows(IllegalStateException.class,()->service.recover(id,"other",()->true)); + var extra=new HashMap<>(request.observations());extra.put("other",UUID.randomUUID().toString()); + assertThrows(IllegalStateException.class,()->service.prepare(new ProjectMutationAdmissionPreparation.Request(id,base.project,1,"new-upload",request.heldSha256(),base.prepared.id(),"moderator",extra,false),()->true)); + } + @Test void configurationAndHeldChangesCannotReuseDecision(){ + base.check();var request=request(false);service.prepare(request,()->true);config="d".repeat(64);assertThrows(RuntimeException.class,()->service.prepare(request,()->true)); + config="c".repeat(64);base.base.base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",base.project),new Document("$set",new Document("versions.1.hash","0".repeat(64)))); + assertThrows(RuntimeException.class,()->service.prepare(request,()->true)); + } + @Test void expiryAllowsHistoryReadButNeverExtendsAdmission(){ + base.check();var request=request(false);var decision=service.prepare(request,()->true);var later=create(base.base.base.base.archive,Clock.offset(clock,Duration.ofMinutes(2))); + assertThrows(IllegalStateException.class,()->later.prepare(request,()->true));base.base.base.base.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(decision,later.recover(id,"moderator",()->true)); + } + @Test void lostRetentionReplyRecoversOnlyExactSignedDecision(){ + base.check();var archive=spy(base.base.base.base.archive);var once=new AtomicBoolean();doAnswer(call->{var result=call.callRealMethod();if(!once.getAndSet(true))throw new IllegalStateException("reply lost");return result;}).when(archive).retain(any()); + var result=create(archive,clock).prepare(request(false),()->true);assertTrue(once.get());assertEquals(result,service.recover(id,"moderator",()->true)); + } + @Test void purelyNewUploadNeedsNoObservationOfUnchangedOlderVersions(){ + var f=base.base.base.base.fixture;var old=new RawBsonDocument(base.base.base.base.archive.load(base.prepared.beforeArchiveId()).versionBytes()).decode(new DocumentCodec()); + f.mongo.getCollection("projects").replaceOne(new Document("_id",base.project),old);var captured=base.base.base.service.capture(base.project,()->true); + var versions=new ArrayList<>(old.getList("versions",Document.class));versions.add(new Document("_id","fresh").append("fileUrl",f.binding.filePath()).append("hash",f.binding.artifactSha256()).append("reviewStatus","PENDING"));old.put("versions",versions); + var proposal=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),base.project,captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(old)); + var prepared=base.base.base.service.prepare(proposal,()->true);assertEquals("APPLIED",base.base.executor().apply(prepared,"owner",()->true).state()); + var held=new RawReviewSnapshotReader(f.mongo).capture(base.project,2,"fresh"); + var request=new ProjectMutationAdmissionPreparation.Request(id,base.project,2,"fresh",held.sha256(),prepared.id(),"moderator",Map.of(),false); + assertEquals("PRIOR_WORK_ACCOUNTED",service.prepare(request,()->true).rule());assertEquals(1,f.gets.get());assertEquals(0,f.posts.get()); + } + @Test void unresolvedOriginalRequiresAcknowledgmentWithoutInventingAnObservation(){ + var f=base.base.base.base.fixture;var old=new RawBsonDocument(base.base.base.base.archive.load(base.prepared.beforeArchiveId()).versionBytes()).decode(new DocumentCodec()); + old.getList("versions",Document.class).getFirst().remove("scanResult");f.mongo.getCollection("projects").replaceOne(new Document("_id",base.project),old); + var proposal=base.base.base.request();var next=new RawBsonDocument(proposal.proposedProject()).decode(new DocumentCodec()); + next.getList("versions",Document.class).get(1).putAll(new Document("fileUrl",f.binding.filePath()).append("hash",f.binding.artifactSha256())); + base.prepared=base.base.base.service.prepare(new ProjectMutationPreparation.Request(proposal.id(),base.project,proposal.expectedSha256(),"owner",proposal.mutation(),VersionMutationPreparationTest.bytes(next)),()->true); + assertEquals("APPLIED",base.base.executor().apply(base.prepared,"owner",()->true).state());var held=new RawReviewSnapshotReader(f.mongo).capture(base.project,1,"new-upload"); + var request=new ProjectMutationAdmissionPreparation.Request(id,base.project,1,"new-upload",held.sha256(),base.prepared.id(),"moderator",Map.of(),false); + assertThrows(IllegalStateException.class,()->service.prepare(request,()->true)); + assertEquals("ACKNOWLEDGED_UNCERTAINTY",service.prepare(new ProjectMutationAdmissionPreparation.Request(id,base.project,1,"new-upload",held.sha256(),base.prepared.id(),"moderator",Map.of(),true),()->true).rule()); + } + @Test void revokedPermissionDuringConfigurationCannotRetainDecision(){ + base.check();var allowed=new AtomicBoolean(true);var spyClient=spy(base.base.base.base.fixture.client);doAnswer(call->{var result=call.callRealMethod();allowed.set(false);return result;}).when(spyClient).configuration(any(),any()); + var restricted=new ProjectMutationAdmissionPreparation(base.base.base.base.fixture.mongo,base.budget,base.base.base.base.archive,base.history,prior,base.accounting,spyClient,clock,60000); + assertThrows(SecurityException.class,()->restricted.prepare(request(false),allowed::get));assertNull(base.base.base.base.archive.find(id)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionReaderTest.java new file mode 100644 index 000000000..c5e8b180b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAdmissionReaderTest.java @@ -0,0 +1,68 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.types.Binary; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationAdmissionReaderTest { + ProjectMutationActivatorTest base=new ProjectMutationActivatorTest();ProjectMutationAdmissionReader reader; + @BeforeEach void setup()throws Exception{base.setup();assertEquals("APPLIED",base.activate().state());reader=new ProjectMutationAdmissionReader(base.f().mongo,base.base.base.base.base.base.archive);} + @AfterEach void cleanup(){base.cleanup();} + Object project(){return base.base.base.project;} + Document root(){return base.f().mongo.getCollection("projects").find().first();} + void heads(){base.base.base.history.requireHeldHeads(project(),VersionMutationPreparationTest.bytes(root()),()->true);} + void change(String path,Object value){base.f().mongo.getCollection("projects").updateOne(new Document("_id",project()),new Document("$set",new Document("versions.1."+path,value)));} + @Test void pendingAdmissionAllowsMetadataAndWorkerProgress(){ + heads();change("changelog","revised notes");change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + change("scanResult.remotePoll",new Document("token",null).append("leaseUntil",new Date()).append("nextPollAt",new Date()));heads(); + assertEquals(base.decision,reader.read(project(),base.decision.binding().requestId(),()->true).decision()); + } + @ParameterizedTest @ValueSource(strings={"hash","gameVersions","replacementSecurityHold","versionMutation.beforeSha256","scanResult.remoteReview.reviewConfigSha256","scanResult.remoteReview.origin.deploymentId","scanResult.scanAttempt","unexpected"}) + void changedArtifactAuthorityOrUnknownFieldsReject(String field){ + Object value=switch(field){case "gameVersions"->List.of("changed");case "scanResult.scanAttempt"->3;case "replacementSecurityHold","scanResult.remoteReview.origin.deploymentId"->UUID.randomUUID().toString();default->"0".repeat(64);}; + change(field,value);assertThrows(RuntimeException.class,this::heads); + } + @Test void missingMutationPointerCannotHideAnExistingAdmission(){ + change("versionMutation",null);assertThrows(IllegalStateException.class,this::heads); + } + @Test void restoredHeldBytesCannotHideAnExistingAdmission(){ + var source=reader.read(project(),base.decision.binding().requestId(),()->true).source(); + var payload=new RawBsonDocument(source.versionBytes()).decode(new DocumentCodec());var held=new RawBsonDocument(payload.get("heldVersion",Binary.class).getData()).decode(new DocumentCodec()); + base.f().mongo.getCollection("projects").updateOne(new Document("_id",project()),new Document("$set",new Document("versions.1",held))); + assertThrows(IllegalStateException.class,this::heads); + } + @Test void scheduledAndPrunedBindingsRetainAdmissionProvenance(){ + base.existingWorkerDeliversAndCompletesTheFixedRequestAfterRestart();heads(); + var version=base.version();var binding=version.get("scanResult",Document.class).get("remoteReview",Document.class); + change("retainedRemoteReview",binding);change("scanResult",null);change("reviewStatus","APPROVED");heads(); + // This validates origin only: no clearance result is produced by this reader. + assertNotNull(reader.read(project(),base.decision.binding().requestId(),()->true)); + } + @Test void nextContextEditAccountsForCurrentJobWithoutRepeatingRetiredHistory(){ + base.existingWorkerDeliversAndCompletesTheFixedRequestAfterRestart();var a=base.base.base; + var preparation=a.base.base.service;var captured=preparation.capture(project(),()->true);var next=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec()); + next.getList("versions",Document.class).get(1).put("gameVersions",List.of("new-runtime")); + var workflow=new ProjectMutationWorkflow(a.budget,preparation,a.base.executor(),a.history); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),project(),captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(next)); + var prepared=workflow.prepare(request,()->true);assertEquals("APPLIED",workflow.apply(prepared,"owner",()->true).state());heads(); + var inventory=new ProjectMutationPriorWorkReader(base.f().mongo,a.budget,a.history).read(project(),prepared.id(),()->true); + assertEquals(1,inventory.work().size());assertEquals(ProjectMutationPriorWorkReader.Kind.REMOTE_JOB,inventory.work().getFirst().kind());assertEquals(base.f().job,inventory.work().getFirst().binding().jobId()); + } + @Test void missingOrChangedReceiptCannotAuthenticateAHead(){ + base.f().mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",base.decision.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,this::heads); + } + @Test void historicalReadSurvivesProjectDeletionAndStillRequiresPermission(){ + var result=reader.read(project(),base.decision.binding().requestId(),()->true);base.f().mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(result.decision(),reader.read(project(),base.decision.binding().requestId(),()->true).decision()); + assertThrows(SecurityException.class,()->reader.read(project(),base.decision.binding().requestId(),()->false)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAttemptRecoveryTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAttemptRecoveryTest.java new file mode 100644 index 000000000..94044c213 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAttemptRecoveryTest.java @@ -0,0 +1,45 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationAttemptRecoveryTest { + ProjectMutationAutomaticAdmissionTest base=new ProjectMutationAutomaticAdmissionTest(); + ProjectMutationAttemptRecovery recovery; + @BeforeEach void setup()throws Exception{base.setup();recovery=new ProjectMutationAttemptRecovery(base.f().mongo,base.base.base.budget,base.attempts,base.base.base.base.base.base.archive);recovery.initialize();} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationAdmissionAttempts.Claim claim(){var c=base.candidate();var capture=new RawReviewSnapshotReader(base.f().mongo).capture(c.projectId(),c.versionIndex(),c.versionId());return base.attempts.begin(base.scope(c),capture.sha256(),()->true);} + ProjectMutationAdmissionAttempts.Claim committed(){ + var c=base.candidate();var claim=claim();base.base.base.check(); + var decision=base.base.service.prepare(new ProjectMutationAdmissionPreparation.Request(claim.decisionId(),c.projectId(),c.versionIndex(),c.versionId(),claim.heldSha256(),c.mutationId(),ProjectMutationAutomaticAdmission.ACTOR,Map.of(c.mutationId()+"/v",base.base.base.id),false),()->true); + assertEquals("APPLIED",base.activator.activate(decision,ProjectMutationAutomaticAdmission.ACTOR,()->true).state());return claim; + } + @Test void backgroundRecoveryFindsCommittedAdmissionAfterProjectDeletion()throws Exception{ + var claim=committed();int gets=base.f().gets.get();base.f().mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(List.of(claim.scope()),recovery.page(null,4).candidates()); + try(var scheduler=new ProjectMutationRecoveryScheduler(recovery,new ProjectMutationRecoveryScheduler.Settings(1,4,100,1000))){ + scheduler.start();long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(scheduler.status().processed()==0&&System.nanoTime()true).state());assertTrue(recovery.page(null,4).candidates().isEmpty());assertEquals(gets,base.f().gets.get());assertEquals(0,base.f().posts.get()); + } + @Test void unresolvedExecutionIsNeverReclaimed(){var claim=claim();assertEquals("UNRESOLVED",recovery.recover(claim.scope(),()->true));assertEquals(claim,base.attempts.status(claim.scope(),()->true).current());assertEquals("RUNNING",base.attempts.status(claim.scope(),()->true).state());assertEquals(0,base.f().gets.get());} + @Test void terminalAttentionIsNotRewritten(){var claim=committed();base.attempts.finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,()->true);assertEquals("ATTENTION",recovery.recover(claim.scope(),()->true));assertTrue(recovery.page(null,4).candidates().isEmpty());} + @Test void changedReceiptCannotFinishAnAttempt(){var claim=committed();base.f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).updateOne(new Document("_id",claim.scope().requestId()),new Document("$set",new Document("afterSha256","0".repeat(64))));assertEquals("UNRESOLVED",recovery.recover(claim.scope(),()->true));assertEquals("RUNNING",base.attempts.status(claim.scope(),()->true).state());} + @Test void validReceiptForDifferentHeldBytesCannotFinishAttempt(){var claim=committed();base.f().mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).updateOne(new Document("_id",claim.scope().requestId()),new Document("$set",new Document("attempts.0.heldSha256","0".repeat(64))));assertEquals("UNRESOLVED",recovery.recover(claim.scope(),()->true));assertEquals("RUNNING",base.attempts.status(claim.scope(),()->true).state());} + @Test void permissionRevocationLeavesAttemptRunning(){var claim=committed();assertThrows(SecurityException.class,()->recovery.recover(claim.scope(),()->false));assertEquals("RUNNING",base.attempts.status(claim.scope(),()->true).state());} + @Test void malformedScopeDoesNotPreventPageContinuation(){ + var claim=claim();var records=base.f().mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION); + records.insertOne(new Document("_id","00000000-0000-0000-0000-000000000000").append("state","RUNNING").append("scope",new Document("projectId",List.of("bad"))).append("attempts","x".repeat(1000000))); + var first=recovery.page(null,1);assertEquals(1,first.examined());assertEquals(1,first.unavailable());assertTrue(first.candidates().isEmpty());assertNotNull(first.next()); + var second=recovery.page(first.next(),1);assertEquals(List.of(claim.scope()),second.candidates());assertNull(second.next()); + } + @Test void traversalRequiresIndexAndValidBounds(){assertThrows(IllegalStateException.class,()->recovery.page(null,0));assertThrows(IllegalStateException.class,()->recovery.page(null,65));assertThrows(IllegalStateException.class,()->recovery.page("bad",1));base.f().mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).dropIndex(ProjectMutationAttemptRecovery.INDEX);assertThrows(com.mongodb.MongoException.class,()->recovery.page(null,1));} +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAutomaticAdmissionTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAutomaticAdmissionTest.java new file mode 100644 index 000000000..f9305c20f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationAutomaticAdmissionTest.java @@ -0,0 +1,75 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationAutomaticAdmissionTest { + ProjectMutationAdmissionPreparationTest base=new ProjectMutationAdmissionPreparationTest();ProjectMutationAdmissionAttempts attempts;ProjectMutationActivator activator;ProjectMutationAutomaticAdmission automatic; + @BeforeEach void setup()throws Exception{base.setup();attempts=new ProjectMutationAdmissionAttempts(f().mongo,base.base.budget,8,1000); + activator=new ProjectMutationActivator(f().mongo,base.base.budget,base.service,base.base.base.base.base.archive,new ReviewRepairJournal(f().mongo,base.base.base.base.base.archive));automatic=create();} + RemoteReviewStepIntegrationTest f(){return base.base.base.base.base.fixture;} + ProjectMutationAutomaticAdmission create(){return new ProjectMutationAutomaticAdmission(f().mongo,base.base.budget,base.base.history,base.prior,attempts,base.base.accounting,base.service,activator,base.base.base.base.base.archive);} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationDiscovery.Candidate candidate(){return new ProjectMutationDiscovery(f().mongo).page(null,64).candidates().getFirst();} + ProjectMutationAdmissionAttempts.Scope scope(ProjectMutationDiscovery.Candidate c){return new ProjectMutationAdmissionAttempts.Scope(c.projectId(),c.versionId(),c.mutationId(),c.requestId(),c.attempt());} + @Test void completedOriginalIsAutomaticallyAdmittedWithoutClearance(){ + var c=candidate();var result=automatic.advance(c,()->true);assertEquals("ADMITTED",result.state());assertEquals("ADMITTED",attempts.status(scope(c),()->true).state()); + var retained=base.service.recover(result.decisionId(),ProjectMutationAutomaticAdmission.ACTOR,()->true);assertEquals("PRIOR_WORK_ACCOUNTED",retained.rule());assertEquals(c.requestId(),retained.binding().requestId()); + assertEquals("ADMITTED",create().advance(c,()->true).state());assertEquals(0,f().posts.get());assertEquals(ProjectVersion.ReviewStatus.PENDING,f().mongo.findById(f().project,Project.class).getVersions().get(1).getReviewStatus()); + } + @Test void runningOriginalWaitsThenUsesANewObservationAfterCooldown()throws Exception { + var c=candidate();base.state="RUNNING";base.workState="RUNNING";var first=automatic.advance(c,()->true);assertEquals("WAITING",first.state());assertEquals(1,f().gets.get()); + assertEquals(first,automatic.advance(c,()->true));assertEquals(1,f().gets.get());Thread.sleep(1100);base.state="COMPLETED";base.workState="COMPLETED"; + var next=create().advance(c,()->true);assertEquals("ADMITTED",next.state());assertNotEquals(first.decisionId(),next.decisionId());assertEquals(2,attempts.status(scope(c),()->true).attempts()); + assertEquals(2,f().mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).countDocuments());assertEquals(0,f().posts.get()); + } + @ParameterizedTest @ValueSource(ints={401,403,404,409}) + void unavailableIdentityRequiresAttentionWithoutAutomaticAcknowledgment(int status){base.status=status;var c=candidate();assertEquals("ATTENTION",automatic.advance(c,()->true).state());assertEquals("ATTENTION",automatic.advance(c,()->true).state());assertEquals(1,f().gets.get());assertTrue(new RemoteReviewDiscovery(f().mongo).page(null,64).candidates().isEmpty());} + @ParameterizedTest @ValueSource(ints={429,503}) + void transientReadFailureCanWaitWithoutReplayingAnObservation(int status){base.status=status;var c=candidate();assertEquals("WAITING",automatic.advance(c,()->true).state());assertEquals(1,f().gets.get());assertEquals(0,f().posts.get());} + @Test void completedOriginalWithRunningWorkerWaits(){base.workState="RUNNING";assertEquals("WAITING",automatic.advance(candidate(),()->true).state());} + @Test void cancelledOriginalCannotBeAutomaticallyAcknowledged(){base.state="CANCELLED";base.workState=null;assertEquals("ATTENTION",automatic.advance(candidate(),()->true).state());assertEquals(0,f().posts.get());} + @Test void runningAttemptIsNotReclaimedByAnotherCoordinator(){ + var c=candidate();var captured=new RawReviewSnapshotReader(f().mongo).capture(c.projectId(),c.versionIndex(),c.versionId());var claim=attempts.begin(scope(c),captured.sha256(),()->true); + assertEquals("RUNNING",automatic.advance(c,()->true).state());assertEquals(claim,attempts.status(scope(c),()->true).current());assertEquals(0,f().gets.get()); + } + @Test void committedAdmissionCanReconcileAnInterruptedAttempt(){reconcileCommitted(false);} + @Test void committedProofDoesNotRewriteATerminalAttentionOutcome(){reconcileCommitted(true);} + void reconcileCommitted(boolean attention){ + var c=candidate();var captured=new RawReviewSnapshotReader(f().mongo).capture(c.projectId(),c.versionIndex(),c.versionId());var claim=attempts.begin(scope(c),captured.sha256(),()->true);base.base.check(); + var prepared=base.service.prepare(new ProjectMutationAdmissionPreparation.Request(claim.decisionId(),c.projectId(),c.versionIndex(),c.versionId(),captured.sha256(),c.mutationId(),ProjectMutationAutomaticAdmission.ACTOR,Map.of(c.mutationId()+"/v",base.base.id),false),()->true); + assertEquals("APPLIED",activator.activate(prepared,ProjectMutationAutomaticAdmission.ACTOR,()->true).state());if(attention)attempts.finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,()->true); + assertEquals(attention?"ATTENTION":"RUNNING",attempts.status(scope(c),()->true).state());int before=f().gets.get(); + assertEquals("ADMITTED",create().advance(c,()->true).state());assertEquals(before,f().gets.get());assertEquals(attention?"ATTENTION":"ADMITTED",attempts.status(scope(c),()->true).state()); + } + @Test void staleCandidateAndWithdrawnLifecycleCannotBegin(){ + var c=candidate();var stale=new ProjectMutationDiscovery.Candidate(c.projectId(),c.versionIndex(),c.versionId(),c.mutationId(),c.requestId(),c.attempt()+1); + assertEquals("NO_WORK",automatic.advance(stale,()->true).state());f().mongo.getCollection("projects").updateOne(new Document("_id",c.projectId()),new Document("$set",new Document("status","DRAFT"))); + assertThrows(SecurityException.class,()->automatic.advance(c,()->true));assertEquals(0,f().mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).countDocuments());assertEquals(0,f().gets.get()); + } + @Test void unresolvedPriorIdentityNeverReceivesAutomaticAcknowledgment(){ + base.unresolvedOriginalRequiresAcknowledgmentWithoutInventingAnObservation();f().gets.set(0); + assertEquals("ATTENTION",automatic.advance(candidate(),()->true).state());assertEquals(0,f().gets.get());assertTrue(new RemoteReviewDiscovery(f().mongo).page(null,64).candidates().isEmpty()); + } + @Test void uncertainActivationStopsAutomaticRetry(){ + var c=candidate();var journal=spy(new ReviewRepairJournal(f().mongo,base.base.base.base.base.archive)); + doAnswer(call->{var claim=call.callRealMethod();f().mongo.getCollection(ProjectMutationActivator.ADMISSIONS).insertOne(new Document("_id",c.requestId()).append("decisionId","collision"));return claim;}).when(journal).claim(any(),anyString(),any(),any()); + var racedActivator=new ProjectMutationActivator(f().mongo,base.base.budget,base.service,base.base.base.base.base.archive,journal); + var raced=new ProjectMutationAutomaticAdmission(f().mongo,base.base.budget,base.base.history,base.prior,attempts,base.base.accounting,base.service,racedActivator,base.base.base.base.base.archive); + assertEquals("ATTENTION",raced.advance(c,()->true).state());assertEquals("ATTENTION",attempts.status(scope(c),()->true).state()); + assertTrue(new RemoteReviewDiscovery(f().mongo).page(null,64).candidates().isEmpty());assertEquals(0,f().posts.get()); + } + @Test void freshUploadDoesNotWaitForUnchangedOlderJobs(){ + base.purelyNewUploadNeedsNoObservationOfUnchangedOlderVersions();f().gets.set(0);var c=candidate();assertEquals("fresh",c.versionId()); + assertEquals("ADMITTED",automatic.advance(c,()->true).state());assertEquals(0,f().mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).countDocuments());assertEquals(0,f().posts.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationBackgroundFlowTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationBackgroundFlowTest.java new file mode 100644 index 000000000..129b6a354 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationBackgroundFlowTest.java @@ -0,0 +1,34 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import net.modtale.model.project.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationBackgroundFlowTest { + ProjectMutationAutomaticAdmissionTest base=new ProjectMutationAutomaticAdmissionTest(); + @BeforeEach void setup()throws Exception{base.setup();} + @AfterEach void cleanup(){base.cleanup();} + @Test void backgroundAdmissionAndDeliveryCompleteWithoutAnUploadEnqueue()throws Exception { + var f=base.f();f.server.removeContext("/api/v1/review-jobs");var created=new AtomicBoolean(); + f.route(e->{String path=e.getRequestURI().getPath();if(path.endsWith("/configuration")){ + byte[] body=f.mapper.writeValueAsBytes(Map.of("policyVersion",f.binding.policyVersion(),"reviewConfigSha256",f.binding.reviewConfigSha256()));e.sendResponseHeaders(200,body.length);e.getResponseBody().write(body); + }else if(e.getRequestMethod().equals("POST")){ + f.binding=f.mongo.findById(f.project,Project.class).getVersions().get(1).getScanResult().getRemoteReview();f.job=UUID.randomUUID().toString();created.set(true);f.reply(e,202,"QUEUED"); + }else if(path.contains("/requests/")&&!created.get())f.reply(e,404,"QUEUED");else f.reply(e,200,"COMPLETED","COMPLETED");}); + var bootstrap=new RemoteReviewBootstrap(new RemoteReviewPersistence(f.mongo),f.client,f.step); + try(var delivery=new RemoteReviewScheduler(new RemoteReviewDiscovery(f.mongo),bootstrap,new RemoteReviewScheduler.Settings(1,4,100,1000)); + var admission=new ProjectMutationAdmissionScheduler(new ProjectMutationDiscovery(f.mongo),base.automatic,new ProjectMutationAdmissionScheduler.Settings(1,4,100,1000))) { + delivery.start();admission.start();long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(20);ProjectVersion version=null; + while(System.nanoTime()=1);assertEquals(0,admission.unavailableCandidates());assertEquals(0,admission.status().failures());assertEquals(0,delivery.status().failures()); + } + assertEquals(1,f.mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationCombinedHistoryTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationCombinedHistoryTest.java new file mode 100644 index 000000000..6d9d2863b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationCombinedHistoryTest.java @@ -0,0 +1,38 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationCombinedHistoryTest { + ProjectMutationObservationProgressTest fixture=new ProjectMutationObservationProgressTest(); + @AfterEach void cleanup(){fixture.cleanup();} + @Test void maximumWorkInventoryWithLargeEightGroupHistoryCanCompleteAdmission()throws Exception{ + fixture.setup(255);var base=fixture.base;var f=base.f();var archive=base.base.base.base.base.base.archive; + var historyFixture=new ProjectMutationHistoryBudgetTest();historyFixture.base=base;long sourceBytes=historyFixture.stage(2750000,8); + assertTrue(sourceBytes>60L*1024*1024 && sourceBytes<=64L*1024*1024,"sourceBytes="+sourceBytes); + var candidate=base.candidate();var inventory=base.base.prior.read(candidate.projectId(),candidate.mutationId(),()->true); + assertEquals(8,inventory.groups().size());assertEquals(256,inventory.work().size()); + for(var work:inventory.work()){ + if(work.kind()!=ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)continue; + var receipt=base.base.base.accounting.check(UUID.randomUUID().toString(),candidate.projectId(),work.mutationId(),work.versionId(),()->true); + assertEquals("COMPLETED",receipt.observation().status().workState()); + ProjectMutationProgressTestSupport.retain(f.mongo,base.base.base.budget,base.base.base.accounting,base.scope(candidate),work,receipt); + } + assertEquals(255,fixture.reads.get()); + var preparation=base.base.create(archive,Clock.systemUTC());var activator=new ProjectMutationActivator(f.mongo,base.base.base.budget,preparation,archive,new ReviewRepairJournal(f.mongo,archive)); + var automatic=new ProjectMutationAutomaticAdmission(f.mongo,base.base.base.budget,base.base.base.history,base.base.prior,base.attempts,base.base.base.accounting,preparation,activator,archive); + long started=System.nanoTime();var result=automatic.advance(candidate,()->true);long elapsed=TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started);assertEquals("ADMITTED",result.state()); + var evidence=new RawBsonDocument(archive.load(result.decisionId()).versionBytes()).decode(new DocumentCodec()); + assertEquals(8,evidence.getList("groups",String.class).size());assertEquals(256,evidence.getList("evidence",Document.class).size());assertEquals(255,evidence.get("observations",Document.class).size()); + assertEquals("PRIOR_WORK_ACCOUNTED",evidence.getString("rule"));assertEquals(255,fixture.reads.get());assertEquals(0,f.posts.get()); + System.out.println("combinedGroups=8 jobs=255 sourceBytes="+sourceBytes+" automaticMillis="+elapsed); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationConcurrentAdmissionTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationConcurrentAdmissionTest.java new file mode 100644 index 000000000..cc1b9df7b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationConcurrentAdmissionTest.java @@ -0,0 +1,73 @@ +package net.modtale.service.security.scan; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.model.UpdateOptions; +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.bson.conversions.Bson; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationConcurrentAdmissionTest { + ProjectMutationAutomaticAdmissionTest base=new ProjectMutationAutomaticAdmissionTest(); + @BeforeEach void setup()throws Exception{base.setup();} + @AfterEach void cleanup(){base.cleanup();} + List prepared(){ + var f=base.f();var archive=base.base.base.base.base.base.archive;var project=base.candidate().projectId(); + var original=new RawBsonDocument(archive.load(base.base.base.prepared.beforeArchiveId()).versionBytes()).decode(new DocumentCodec());f.mongo.getCollection("projects").replaceOne(new Document("_id",project),original); + var preparation=base.base.base.base.base.service;var captured=preparation.capture(project,()->true);var versions=new ArrayList<>(original.getList("versions",Document.class)); + for(String id:List.of("alpha","beta"))versions.add(new Document("_id",id).append("reviewStatus","PENDING").append("fileUrl",f.binding.filePath()).append("hash",f.binding.artifactSha256()));original.put("versions",versions); + var group=preparation.prepare(new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),project,captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(original)),()->true); + assertEquals("APPLIED",base.base.base.base.executor().apply(group,"owner",()->true).state());var results=new ArrayList(); + for(var candidate:new ProjectMutationDiscovery(f.mongo).page(null,64).candidates()){ + var held=new RawReviewSnapshotReader(f.mongo).capture(project,candidate.versionIndex(),candidate.versionId()); + results.add(base.base.service.prepare(new ProjectMutationAdmissionPreparation.Request(UUID.randomUUID().toString(),project,candidate.versionIndex(),candidate.versionId(),held.sha256(),candidate.mutationId(),"moderator",Map.of(),false),()->true)); + } + assertEquals(2,results.size());return results; + } + @Test void simultaneousSiblingAdmissionsDoNotLoseVerifiedWorkToWriteConflict()throws Exception{ + var decisions=prepared();var f=base.f();var projects=spy(f.mongo.getCollection("projects")); + doReturn(projects).when(projects).withReadPreference(any());doReturn(projects).when(projects).withReadConcern(any());doReturn(projects).when(projects).withWriteConcern(any());doReturn(projects).when(projects).withTimeout(anyLong(),any()); + var mongo=spy(f.mongo);doReturn(projects).when(mongo).getCollection("projects");var snapshotsReady=new CountDownLatch(2);var writes=new AtomicInteger(); + doAnswer(call->{assertTrue(f.mongo.collectionExists(ProjectMutationActivator.ADMISSIONS));if(writes.getAndIncrement()<2){snapshotsReady.countDown();assertTrue(snapshotsReady.await(10,TimeUnit.SECONDS));}return call.callRealMethod();}) + .when(projects).updateOne(any(ClientSession.class),any(Bson.class),any(Bson.class),any(UpdateOptions.class)); + try(var budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),2);var workers=Executors.newFixedThreadPool(2)){ + var archive=base.base.base.base.base.base.archive;var activator=new ProjectMutationActivator(mongo,budget,base.base.service,archive,new ReviewRepairJournal(f.mongo,archive)); + var first=workers.submit(()->activator.activate(decisions.get(0),"moderator",()->true));var second=workers.submit(()->activator.activate(decisions.get(1),"moderator",()->true)); + var outcomes=List.of(first.get(25,TimeUnit.SECONDS),second.get(25,TimeUnit.SECONDS));assertTrue(outcomes.stream().allMatch(r->"APPLIED".equals(r.state())),outcomes.toString()); + for(var decision:decisions)assertEquals("APPLIED",activator.receipt(decision,"moderator",()->true).state()); + } + assertTrue(writes.get()>=3);assertEquals(2,f.mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments());assertEquals(0,f.posts.get()); + } + @Test void changedHeldBytesAreRecapturedBeforeRetry(){ + var decision=prepared().getFirst();var f=base.f();var projects=spy(f.mongo.getCollection("projects")); + doReturn(projects).when(projects).withReadPreference(any());doReturn(projects).when(projects).withReadConcern(any());doReturn(projects).when(projects).withWriteConcern(any());doReturn(projects).when(projects).withTimeout(anyLong(),any()); + var mongo=spy(f.mongo);doReturn(projects).when(mongo).getCollection("projects");var writes=new AtomicInteger();var archive=base.base.base.base.base.base.archive;var source=archive.load(decision.id()); + doAnswer(call->{writes.incrementAndGet();f.mongo.getCollection("projects").updateOne(new Document("_id",source.projectId()),new Document("$set",new Document("versions."+source.versionIndex()+".hash","0".repeat(64)))); + var conflict=new com.mongodb.MongoException(112,"fixture concurrent artifact edit");conflict.addLabel(com.mongodb.MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL);throw conflict;}) + .when(projects).updateOne(any(ClientSession.class),any(Bson.class),any(Bson.class),any(UpdateOptions.class)); + var activator=new ProjectMutationActivator(mongo,base.base.base.budget,base.base.service,archive,new ReviewRepairJournal(f.mongo,archive)); + assertEquals("NOT_APPLIED",activator.activate(decision,"moderator",()->true).state());assertEquals(1,writes.get());assertEquals(0,f.mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments());assertEquals(0,f.posts.get()); + } + @ParameterizedTest @CsvSource({"112,true,false,3","112,false,false,1","91,true,false,1","112,true,true,1"}) + void retriesRequireExactConflictEvidenceAndConfirmedAbort(int code,boolean labelled,boolean abortFails,int expectedWrites){ + var decision=prepared().getFirst();var f=base.f();var projects=spy(f.mongo.getCollection("projects")); + doReturn(projects).when(projects).withReadPreference(any());doReturn(projects).when(projects).withReadConcern(any());doReturn(projects).when(projects).withWriteConcern(any());doReturn(projects).when(projects).withTimeout(anyLong(),any()); + var mongo=spy(f.mongo);doReturn(projects).when(mongo).getCollection("projects");var writes=new AtomicInteger(); + doAnswer(call->{writes.incrementAndGet();var failure=new com.mongodb.MongoException(code,"fixture database failure");if(labelled)failure.addLabel(com.mongodb.MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL);throw failure;}) + .when(projects).updateOne(any(ClientSession.class),any(Bson.class),any(Bson.class),any(UpdateOptions.class)); + if(abortFails){var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(mongo).getMongoDatabaseFactory(); + doAnswer(call->{var session=spy((ClientSession)call.callRealMethod());doThrow(new com.mongodb.MongoException("abort response unavailable")).when(session).abortTransaction();return session;}).when(factory).getSession(any(com.mongodb.ClientSessionOptions.class));} + var archive=base.base.base.base.base.base.archive;var activator=new ProjectMutationActivator(mongo,base.base.base.budget,base.base.service,archive,new ReviewRepairJournal(f.mongo,archive)); + assertEquals("UNKNOWN",activator.activate(decision,"moderator",()->true).state());assertEquals(expectedWrites,writes.get());assertEquals(0,f.mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments());assertEquals(0,f.posts.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationDiscoveryTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationDiscoveryTest.java new file mode 100644 index 000000000..f655e690a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationDiscoveryTest.java @@ -0,0 +1,52 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationDiscoveryTest { + ProjectMutationAdmissionPreparationTest base=new ProjectMutationAdmissionPreparationTest();ProjectMutationDiscovery discovery; + @BeforeEach void setup()throws Exception{base.setup();discovery=new ProjectMutationDiscovery(base.base.base.base.base.fixture.mongo);} + @AfterEach void cleanup(){base.cleanup();} + Document root(){return base.base.base.base.base.fixture.mongo.getCollection("projects").find().first();} + void save(Document root){base.base.base.base.base.fixture.mongo.getCollection("projects").replaceOne(new Document("_id",root.get("_id")),root);} + @Test void emptySliceContinuesToExactHeldPositionWithoutActivatingAnything(){ + var first=discovery.page(null,1);assertEquals(1,first.examined());assertTrue(first.candidates().isEmpty());assertNotNull(first.next()); + var second=discovery.page(first.next(),1);assertEquals(1,second.candidates().size());var candidate=second.candidates().getFirst(); + assertEquals(root().get("_id"),candidate.projectId());assertInstanceOf(ObjectId.class,candidate.projectId());assertEquals(1,candidate.versionIndex());assertEquals("new-upload",candidate.versionId());assertEquals(base.base.prepared.id(),candidate.mutationId()); + assertTrue(discovery.page(second.next(),1).candidates().isEmpty());assertEquals(0,base.base.base.base.base.fixture.posts.get()); + } + @ParameterizedTest @ValueSource(strings={"DRAFT","ARCHIVED","DELETED"}) + void withdrawnProjectsAreNotAdmissionCandidates(String status){var root=root();root.put("status",status);save(root);assertEquals(0,discovery.page(null,64).examined());} + @ParameterizedTest @ValueSource(strings={"scanRequestId","scanAttempt","manualRescan","reviewStatus","pointer","pointerRequest","pointerExtra","versionId"}) + void malformedHeldIdentityIsReportedWithoutBecomingEligible(String field){ + var root=root();var version=root.getList("versions",Document.class).get(1);var scan=version.get("scanResult",Document.class);var pointer=version.get("versionMutation",Document.class); + switch(field){case "scanRequestId"->scan.put(field,List.of(UUID.randomUUID().toString()));case "scanAttempt"->scan.put(field,1.0);case "manualRescan"->scan.put(field,true);case "reviewStatus"->version.put(field,"APPROVED");case "pointer"->version.put("versionMutation","bad");case "pointerRequest"->pointer.put("requestId",UUID.randomUUID().toString());case "pointerExtra"->pointer.put("extra",true);case "versionId"->version.put("_id","bad\n");} + save(root);var page=discovery.page(null,64);assertTrue(page.candidates().isEmpty());assertEquals(1,page.unavailable()); + } + @Test void boundedPagesTraverseLargeVersionListsWithoutReturningArtifactPayloads(){ + var root=root();var template=root.getList("versions",Document.class).get(1);var versions=new ArrayList(); + for(int i=0;i<130;i++){var copy=Document.parse(template.toJson());copy.put("_id","v"+i);copy.put("payload","x".repeat(10000));versions.add(copy);}root.put("versions",versions);save(root); + var cursor=(ProjectMutationDiscovery.Cursor)null;var positions=new ArrayList();int examined=0; + for(int i=0;i<5;i++){var page=discovery.page(cursor,64);assertTrue(page.candidates().size()<=64);examined+=page.examined();page.candidates().forEach(c->positions.add(c.versionIndex()));cursor=page.next();if(cursor==null)break;} + assertEquals(130,examined);assertEquals(java.util.stream.IntStream.range(0,130).boxed().toList(),positions); + } + @Test void textAndObjectIdsWithIdenticalDisplayRemainDifferentScopes(){ + var original=root();var copy=Document.parse(original.toJson());copy.put("_id",original.get("_id").toString());base.base.base.base.base.fixture.mongo.getCollection("projects").insertOne(copy); + var first=discovery.page(null,64);var second=discovery.page(first.next(),64);assertEquals(1,first.candidates().size());assertEquals(1,second.candidates().size()); + assertInstanceOf(String.class,first.candidates().getFirst().projectId());assertInstanceOf(ObjectId.class,second.candidates().getFirst().projectId()); + } + @Test void unsupportedLongUtf16ProjectIdDoesNotPoisonContinuation(){ + var copy=Document.parse(root().toJson());copy.put("_id","😀".repeat(100));base.base.base.base.base.fixture.mongo.getCollection("projects").insertOne(copy); + var first=discovery.page(null,64);assertTrue(first.candidates().isEmpty());assertEquals(1,first.unavailable()); + assertEquals(1,discovery.page(first.next(),64).candidates().size()); + } + @Test void limitsRejectBeforeQuery(){assertThrows(IllegalArgumentException.class,()->discovery.page(null,0));assertThrows(IllegalArgumentException.class,()->discovery.page(null,65));assertThrows(IllegalArgumentException.class,()->new ProjectMutationDiscovery.Cursor("p",-1,false));} +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationExecutorTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationExecutorTest.java new file mode 100644 index 000000000..ffa23a9ec --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationExecutorTest.java @@ -0,0 +1,142 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.*; +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationExecutorTest { + ProjectMutationPreparationTest base=new ProjectMutationPreparationTest();ReviewRepairJournal journal; + @BeforeEach void setup()throws Exception{base.setup();journal=new ReviewRepairJournal(base.base.fixture.mongo,base.base.archive);} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationExecutor executor(){return new ProjectMutationExecutor(base.base.fixture.mongo,base.service,base.base.archive,journal);} + Document root(){return base.base.raw();} + @Test void replacementGroupCommitsRemovalNewRequestAndPreservedVersionTogether(){ + var unchanged=new Document(root().getList("versions",Document.class).get(1));var prepared=base.service.prepare(base.request(),()->true); + var result=executor().apply(prepared,"owner",()->true);assertEquals("APPLIED",result.state());var versions=root().getList("versions",Document.class); + assertEquals(List.of("w","new-upload"),versions.stream().map(v->v.getString("_id")).toList());assertEquals(unchanged,versions.getFirst()); + var scan=versions.get(1).get("scanResult",Document.class);assertEquals("MUTATION_HELD",scan.get("scanState"));assertEquals(1,scan.get("scanAttempt")); + var ref=base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).find().first();assertEquals(result.afterSha256(),ref.get("afterSha256")); + assertTrue(ref.getList("versions",Document.class).stream().anyMatch(v->"v".equals(v.get("versionId")) && "REMOVED".equals(v.get("state")))); + assertEquals(result,executor().apply(prepared,"owner",()->true));assertEquals(1,base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).countDocuments()); + assertEquals(0,base.base.fixture.posts.get());assertEquals(0,base.base.fixture.gets.get()); + } + @Test void submissionRetainsPrunedHistoryAndUsesDistinctRequests(){ + var f=base.base.fixture;f.change("retainedRemoteReview",f.mongo.getConverter().convertToMongoType(f.saved().getRemoteReview()));f.change("scanResult",null); + f.mongo.getCollection("projects").updateOne(new Document("_id",root().get("_id")),new Document("$set",new Document("status","DRAFT"))); + var captured=base.service.capture(root().get("_id"),()->true);var proposed=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec());proposed.put("status","PENDING"); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),captured.projectId(),captured.sha256(),"owner",ProjectMutationPreparation.Mutation.SUBMISSION,VersionMutationPreparationTest.bytes(proposed)); + var prepared=base.service.prepare(request,()->true);assertEquals("APPLIED",executor().apply(prepared,"owner",()->true).state());assertEquals("PENDING",root().get("status")); + var versions=root().getList("versions",Document.class);assertEquals(2,versions.getFirst().get("scanResult",Document.class).get("scanAttempt")); + assertNotEquals(versions.getFirst().get("scanResult",Document.class).get("scanRequestId"),versions.get(1).get("scanResult",Document.class).get("scanRequestId")); + assertEquals(2,f.mongo.getCollection(ProjectMutationExecutor.REFERENCES).find().first().getList("versions",Document.class).size()); + } + @Test void changedOwnerOrConcurrentMetadataPreventsTheEntireGroup(){var prepared=base.service.prepare(base.request(),()->true);base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",root().get("_id")),new Document("$set",new Document("authorId","changed")));assertEquals("NOT_APPLIED",executor().apply(prepared,"owner",()->true).state());assertEquals(2,root().getList("versions",Document.class).size());assertEquals(0,base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).countDocuments());} + @Test void lostCommitReplyRecoversWithoutReapplyingAnyChild(){ + var prepared=base.service.prepare(base.request(),()->true);var mongo=spy(base.base.fixture.mongo);var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(mongo).getMongoDatabaseFactory(); + doAnswer(call->{var session=spy((ClientSession)call.callRealMethod());doAnswer(commit->{commit.callRealMethod();throw new MongoException("lost commit reply");}).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ProjectMutationExecutor(mongo,base.service,base.base.archive,journal).apply(prepared,"owner",()->true);assertEquals("APPLIED",result.state());assertEquals(result,executor().apply(prepared,"owner",()->true)); + } + @Test void revocationAfterReferenceInsertRollsBackEveryVersion(){ + var prepared=base.service.prepare(base.request(),()->true);var before=root();var mongo=spy(base.base.fixture.mongo);var references=spy(mongo.getCollection(ProjectMutationExecutor.REFERENCES));var allowed=new AtomicBoolean(true); + doReturn(references).when(mongo).getCollection(ProjectMutationExecutor.REFERENCES); + doAnswer(call->{call.callRealMethod();allowed.set(false);return null;}).when(references).insertOne(any(ClientSession.class),any(Document.class)); + assertThrows(SecurityException.class,()->new ProjectMutationExecutor(mongo,base.service,base.base.archive,journal).apply(prepared,"owner",allowed::get)); + assertEquals(before,root());assertEquals(0,references.countDocuments()); + } + @Test void newUploadCannotSmuggleApprovedEvidenceIntoTheGroup(){ + var request=base.request();var proposed=new RawBsonDocument(request.proposedProject()).decode(new DocumentCodec());proposed.getList("versions",Document.class).get(1).put("approvedSecurityEvidence",new Document("clearanceGranted",true)); + var changed=new ProjectMutationPreparation.Request(request.id(),request.projectId(),request.expectedSha256(),request.actor(),request.mutation(),VersionMutationPreparationTest.bytes(proposed)); + var prepared=base.service.prepare(changed,()->true);assertThrows(IllegalStateException.class,()->executor().apply(prepared,"owner",()->true));assertEquals(0,base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).countDocuments()); + } + @Test void groupContextChangePreservesAnExistingSecurityBlock(){ + base.base.fixture.change("scanResult.verdict","BLOCK");var captured=base.service.capture(root().get("_id"),()->true);var proposed=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec()); + proposed.getList("versions",Document.class).getFirst().put("gameVersions",List.of("changed")); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),captured.projectId(),captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(proposed)); + var prepared=base.service.prepare(request,()->true);assertEquals("APPLIED",executor().apply(prepared,"owner",()->true).state()); + var version=root().getList("versions",Document.class).getFirst();assertEquals(prepared.id(),version.get("replacementSecurityHold"));assertEquals("BLOCK",version.get("scanResult",Document.class).get("verdict")); + assertEquals(2,version.get("scanResult",Document.class).get("scanAttempt")); + } + @Test void failedReferenceInsertCannotLeaveAPartiallyAppliedGroup(){ + var prepared=base.service.prepare(base.request(),()->true);var before=root();base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).insertOne(new Document("_id",prepared.id()).append("unexpected",true)); + assertEquals("UNKNOWN",executor().apply(prepared,"owner",()->true).state());assertEquals(before,root()); + assertEquals(1,base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).countDocuments()); + } + + @Test void newUploadCannotIntroduceUnknownFieldsOrMappingTypes(){ + for(String field:List.of("futureAuthority","_class")){ + var request=base.request();var proposed=new RawBsonDocument(request.proposedProject()).decode(new DocumentCodec());proposed.getList("versions",Document.class).get(1).put(field,"unexpected"); + var changed=new ProjectMutationPreparation.Request(request.id(),request.projectId(),request.expectedSha256(),request.actor(),request.mutation(),VersionMutationPreparationTest.bytes(proposed)); + var prepared=base.service.prepare(changed,()->true);assertThrows(IllegalStateException.class,()->executor().apply(prepared,"owner",()->true)); + } + } + + @Test void numericBsonTypeChangeCannotPassTheRootComparison(){ + var projects=base.base.fixture.mongo.getCollection("projects");var query=new Document("_id",root().get("_id"));projects.updateOne(query,new Document("$set",new Document("unknownCounter",1))); + var prepared=base.service.prepare(base.request(),()->true);projects.updateOne(query,new Document("$set",new Document("unknownCounter",1L))); + assertEquals("NOT_APPLIED",executor().apply(prepared,"owner",()->true).state());assertInstanceOf(Long.class,root().get("unknownCounter")); + } + + @Test void appliedArchiveRetainsExactCommittedRootAfterDeletion(){ + var prepared=base.service.prepare(base.request(),()->true);var result=executor().apply(prepared,"owner",()->true); + var ref=base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).find().first(); + var applied=base.base.archive.load(ref.getString("afterArchiveId")); + assertEquals(ReviewSnapshotArchive.Action.PROJECT_MUTATION_APPLIED,applied.action()); + assertArrayEquals(VersionMutationPreparationTest.bytes(root()),applied.versionBytes()); + assertEquals(prepared.createdAt(),root().getList("versions",Document.class).get(1).get("scanResult",Document.class).get("scanTimestamp")); + base.base.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(result,executor().receipt(prepared,"owner",()->true)); + } + @Test void appliedReceiptRejectsChangedDigestOrMissingArchiveBinding(){ + var prepared=base.service.prepare(base.request(),()->true);executor().apply(prepared,"owner",()->true); + var ops=base.base.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION);var query=new Document("_id",prepared.id()); + ops.updateOne(query,new Document("$set",new Document("afterSha256","0".repeat(64)))); + assertThrows(IllegalStateException.class,()->executor().receipt(prepared,"owner",()->true)); + ops.updateOne(query,new Document("$unset",new Document("afterArchiveId",""))); + assertEquals("UNKNOWN",executor().receipt(prepared,"owner",()->true).state()); + } + @Test void lostAppliedArchiveReplyRecoversExactCandidate(){ + var prepared=base.service.prepare(base.request(),()->true);var archive=spy(base.base.archive); + doAnswer(call->{var snapshot=(ReviewSnapshotArchive.Snapshot)call.getArgument(0);var result=call.callRealMethod(); + if(snapshot.action()==ReviewSnapshotArchive.Action.PROJECT_MUTATION_APPLIED)throw new MongoException("lost archive reply");return result; + }).when(archive).retain(any(ReviewSnapshotArchive.Snapshot.class)); + var result=new ProjectMutationExecutor(base.base.fixture.mongo,base.service,archive,journal).apply(prepared,"owner",()->true); + assertEquals("APPLIED",result.state());assertEquals(result,executor().receipt(prepared,"owner",()->true)); + } + + @Test void retryAfterRetentionBeforeClaimUsesIdenticalProjection(){ + var prepared=base.service.prepare(base.request(),()->true);var archive=spy(base.base.archive);var allowed=new AtomicBoolean(true); + doAnswer(call->{var result=call.callRealMethod();allowed.set(false);return result;}).when(archive).retain(any(ReviewSnapshotArchive.Snapshot.class)); + assertThrows(SecurityException.class,()->new ProjectMutationExecutor(base.base.fixture.mongo,base.service,archive,journal).apply(prepared,"owner",allowed::get)); + assertEquals(0,base.base.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).countDocuments()); + assertEquals("APPLIED",executor().apply(prepared,"owner",()->true).state()); + } + + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void cacheInvalidationExceptionCannotRemoveOrdinaryArtifactsOrUnchangedPackCache(boolean modpack){ + if(modpack)base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",root().get("_id")),new Document("$set",new Document("classification","MODPACK"))); + var captured=base.service.capture(root().get("_id"),()->true);var proposed=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec());var version=proposed.getList("versions",Document.class).getFirst(); + version.put("fileUrl",null);if(!modpack)version.put("gameVersions",List.of("changed")); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),captured.projectId(),captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(proposed)); + var prepared=base.service.prepare(request,()->true);assertThrows(IllegalStateException.class,()->executor().apply(prepared,"owner",()->true)); + assertArrayEquals(captured.bytes(),VersionMutationPreparationTest.bytes(root())); + } + + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void classificationChangeRequiresNewUploadAndSupportedTypes(boolean added){ + base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",root().get("_id")),new Document("$set",new Document("classification","DATA"))); + var captured=base.service.capture(root().get("_id"),()->true);var proposed=added?new RawBsonDocument(base.request().proposedProject()).decode(new DocumentCodec()):root(); + proposed.put("classification",added?"MODPACK":"PLUGIN"); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),captured.projectId(),captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(proposed)); + var prepared=base.service.prepare(request,()->true);assertThrows(IllegalStateException.class,()->executor().apply(prepared,"owner",()->true));assertArrayEquals(captured.bytes(),VersionMutationPreparationTest.bytes(root())); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationFairnessTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationFairnessTest.java new file mode 100644 index 000000000..94c5fb4d0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationFairnessTest.java @@ -0,0 +1,55 @@ +package net.modtale.service.security.scan; + +import com.mongodb.WriteConcern; +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationFairnessTest { + ProjectMutationObservationProgressTest large=new ProjectMutationObservationProgressTest(); + ProjectMutationAutomaticAdmissionTest small=new ProjectMutationAutomaticAdmissionTest(); + @AfterEach void cleanup(){try{small.cleanup();}finally{large.cleanup();}} + @Test void smallProjectFinishesBeforeEarlierLongHistoryOnOneWorker()throws Exception{ + large.setup(16);small.f().project="ffffffffffffffffffffffff";small.setup(); + var a=large.base.f();var b=small.f();var first=large.base.candidate();var second=small.candidate(); + for(String name:b.mongo.getCollectionNames()){ + var target=a.mongo.getCollection(name).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + try(var rows=b.mongo.getCollection(name).find().iterator()){while(rows.hasNext())target.insertOne(rows.next());} + } + var jobsAtSmallCompletion=new AtomicInteger(-1);long started=System.nanoTime(); + try(var budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),2); + var p=new ProjectMutationParallelHistoryTest().pipeline(a.mongo,budget,large.base.base.base.base.base.base.archive,a.client); + var q=new ProjectMutationParallelHistoryTest().pipeline(a.mongo,budget,large.base.base.base.base.base.base.archive,b.client)){ + // Route the two isolated HTTP fixtures to real coordinators; discovery, scheduling and persistence remain real. + var dispatch=mock(ProjectMutationAutomaticAdmission.class); + when(dispatch.advance(any(),any())).thenAnswer(call->{ + var candidate=call.getArgument(0,ProjectMutationDiscovery.Candidate.class);var allowed=call.getArgument(1,BooleanSupplier.class); + assertTrue(candidate.projectId().equals(first.projectId())||candidate.projectId().equals(second.projectId())); + var result=(candidate.projectId().equals(first.projectId())?p:q).automatic().advance(candidate,allowed); + if(candidate.projectId().equals(second.projectId())&&"ADMITTED".equals(result.state()))jobsAtSmallCompletion.compareAndSet(-1,large.reads.get()); + return result; + }); + try(var scheduler=new ProjectMutationAdmissionScheduler(new ProjectMutationDiscovery(a.mongo),dispatch,new ProjectMutationAdmissionScheduler.Settings(1,1,100,10000))){ + scheduler.start();long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(60); + while(System.nanoTime()0 && jobsAtSmallCompletion.get()<16,"jobsBeforeSmall="+jobsAtSmallCompletion.get()); + assertEquals(16,large.reads.get());assertEquals(17,a.mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).countDocuments()); + assertEquals(0,a.posts.get());assertEquals(0,b.posts.get()); + for(var candidate:java.util.List.of(first,second)){ + var ledger=a.mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).find(new Document("_id",candidate.requestId())).first(); + assertEquals("ADMITTED",ledger.getString("state"));assertEquals(candidate.equals(first)?16:1,ledger.getList("attempts",Document.class).size()); + } + System.out.println("fairnessLongJobs=16 shortJobs=1 longJobsAtShortCompletion="+jobsAtSmallCompletion.get()+" totalMillis="+TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationHistoryBudgetTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationHistoryBudgetTest.java new file mode 100644 index 000000000..2e5c796be --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationHistoryBudgetTest.java @@ -0,0 +1,59 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationHistoryBudgetTest { + ProjectMutationAutomaticAdmissionTest base=new ProjectMutationAutomaticAdmissionTest(); + @BeforeEach void setup()throws Exception{base.setup();} + @AfterEach void cleanup(){base.cleanup();} + @ParameterizedTest @CsvSource({"2790000,8,false","2800000,8,true","0,9,true"}) + void historyBoundsNeverPermitPartialAdmission(int padding,int groupCount,boolean exceedsLimit){ + long sourceBytes=stage(padding,groupCount);var f=base.f();var archive=base.base.base.base.base.base.archive;var project=base.candidate().projectId(); + var candidate=base.candidate();long started=System.nanoTime(); + assertEquals(exceedsLimit,sourceBytes>64L*1024*1024 || groupCount>8); + if(exceedsLimit){ + assertThrows(ProjectMutationPriorWorkReader.LimitExceeded.class,()->base.base.prior.read(project,candidate.mutationId(),()->true)); + assertEquals("ATTENTION",base.automatic.advance(candidate,()->true).state()); + assertEquals("ATTENTION",base.create().advance(candidate,()->true).state()); + assertEquals(1,base.attempts.status(base.scope(candidate),()->true).attempts()); + assertEquals(0,f.mongo.getCollection(ProjectMutationActivator.ADMISSIONS).countDocuments());assertEquals(0,f.gets.get()); + }else{ + assertTrue(sourceBytes>60L*1024*1024);var inventory=base.base.prior.read(project,candidate.mutationId(),()->true);assertEquals(8,inventory.groups().size()); + assertEquals(1,inventory.work().stream().filter(w->w.kind()==ProjectMutationPriorWorkReader.Kind.REMOTE_JOB).count()); + var admission=base.base.create(archive,Clock.systemUTC());var activator=new ProjectMutationActivator(f.mongo,base.base.base.budget,admission,archive,new ReviewRepairJournal(f.mongo,archive)); + var automatic=new ProjectMutationAutomaticAdmission(f.mongo,base.base.base.budget,base.base.base.history,base.base.prior,base.attempts,base.base.base.accounting,admission,activator,archive); + var result=automatic.advance(candidate,()->true);assertEquals("ADMITTED",result.state()); + var decision=new RawBsonDocument(archive.load(result.decisionId()).versionBytes()).decode(new DocumentCodec());assertEquals(8,decision.getList("groups",String.class).size());assertEquals("PRIOR_WORK_ACCOUNTED",decision.getString("rule")); + } + assertEquals(0,f.posts.get());System.out.println("historyGroups="+groupCount+" sourceBytes="+sourceBytes+" validationMillis="+TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started)); + } + long stage(int padding,int groupCount){ + var f=base.f();var archive=base.base.base.base.base.base.archive;var project=base.candidate().projectId(); + var source=new RawBsonDocument(base.base.base.history.read(project,base.candidate().mutationId(),()->true).evidence().before().versionBytes()).decode(new DocumentCodec()); + source.put("fixturePadding","x".repeat(padding));f.mongo.getCollection("projects").replaceOne(new Document("_id",project),source); + var preparation=new ProjectMutationPreparation(f.mongo,archive,Clock.systemUTC(),60000); + var executor=new ProjectMutationExecutor(f.mongo,preparation,archive,new ReviewRepairJournal(f.mongo,archive)); + var groups=new ArrayList();long sourceBytes=0; + for(int i=0;itrue);var next=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec()); + if(i==0)next.put("versions",List.of(next.getList("versions",Document.class).get(1),new Document("_id","new-upload").append("reviewStatus","PENDING").append("fileUrl",f.binding.filePath()).append("hash",f.binding.artifactSha256()))); + else next.getList("versions",Document.class).get(1).put("gameVersions",List.of("runtime-"+i)); + var prepared=preparation.prepare(new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),project,captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(next)),()->true); + assertEquals("APPLIED",executor.apply(prepared,"owner",()->true).state());groups.add(prepared); + sourceBytes+=archive.load(prepared.beforeArchiveId()).versionBytes().length+archive.load(prepared.afterArchiveId()).versionBytes().length; + String applied=UUID.nameUUIDFromBytes(("project-mutation-applied-1:"+prepared.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString();sourceBytes+=archive.load(applied).versionBytes().length; + } + assertEquals(groups.getLast().id(),base.candidate().mutationId());return sourceBytes; + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationInventoryScaleTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationInventoryScaleTest.java new file mode 100644 index 000000000..3e2f4488b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationInventoryScaleTest.java @@ -0,0 +1,52 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationInventoryScaleTest { + ProjectMutationObservationProgressTest fixture=new ProjectMutationObservationProgressTest(); + @AfterEach void cleanup(){fixture.cleanup();} + @Test void readBatchAuthenticatesEachReceiptAndCannotEscapeItsScope()throws Exception{ + fixture.setup(2);var base=fixture.base;var candidate=base.candidate();var ids=new LinkedHashMap(); + for(var work:base.base.prior.read(candidate.projectId(),candidate.mutationId(),()->true).work()){ + if(work.kind()!=ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)continue;String id=UUID.randomUUID().toString(); + base.base.base.accounting.check(id,candidate.projectId(),work.mutationId(),work.versionId(),()->true);ids.put(work.versionId(),id); + } + ProjectMutationProgressTestSupport.assertReadBatchBoundaries(base.f().mongo,base.base.base.budget,base.base.base.history,base.f().client,base.scope(candidate),ids); + assertEquals(2,fixture.reads.get());assertEquals(0,base.f().posts.get()); + } + @ParameterizedTest @ValueSource(ints={16,255}) + void completeInventoryCanBeVerifiedAndActivatedWithinSharedWorkWindows(int jobs)throws Exception{ + fixture.setup(jobs);var base=fixture.base;var f=base.f();var candidate=base.candidate(); + var inventory=base.base.prior.read(candidate.projectId(),candidate.mutationId(),()->true);assertEquals(jobs+1,inventory.work().size()); + var ids=new LinkedHashMap();long observationsStarted=System.nanoTime(); + for(var work:inventory.work()){ + if(work.kind()!=ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)continue; + String id=UUID.randomUUID().toString();var receipt=base.base.base.accounting.check(id,candidate.projectId(),work.mutationId(),work.versionId(),()->true); + assertEquals("COMPLETED",receipt.observation().status().workState());ids.put(work.mutationId()+"/"+work.versionId(),id); + ProjectMutationProgressTestSupport.retain(f.mongo,base.base.base.budget,base.base.base.accounting,base.scope(candidate),work,receipt); + } + assertEquals(jobs,fixture.reads.get());assertEquals(jobs,ids.size()); + var captured=new RawReviewSnapshotReader(f.mongo).capture(candidate.projectId(),candidate.versionIndex(),candidate.versionId()); + var archive=base.base.base.base.base.base.archive;var preparation=base.base.create(archive,Clock.systemUTC()); + long started=System.nanoTime();var prepared=preparation.prepare(new ProjectMutationAdmissionPreparation.Request(UUID.randomUUID().toString(),candidate.projectId(),candidate.versionIndex(),candidate.versionId(),captured.sha256(),candidate.mutationId(),"scale-reviewer",ids,false),()->true); + long prepareMillis=TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started); + var evidence=new RawBsonDocument(archive.load(prepared.id()).versionBytes()).decode(new DocumentCodec());assertEquals(jobs+1,evidence.getList("evidence",Document.class).size());assertEquals(jobs,evidence.get("observations",Document.class).size()); + var activator=new ProjectMutationActivator(f.mongo,base.base.base.budget,preparation,archive,new ReviewRepairJournal(f.mongo,archive));started=System.nanoTime(); + var automatic=new ProjectMutationAutomaticAdmission(f.mongo,base.base.base.budget,base.base.base.history,base.base.prior,base.attempts,base.base.base.accounting,preparation,activator,archive); + var result=automatic.advance(candidate,()->true);assertEquals("ADMITTED",result.state());long activateMillis=TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started); + var finalEvidence=new RawBsonDocument(archive.load(result.decisionId()).versionBytes()).decode(new DocumentCodec());assertEquals(jobs+1,finalEvidence.getList("evidence",Document.class).size());assertEquals(jobs,finalEvidence.get("observations",Document.class).size()); + assertEquals(jobs,fixture.reads.get());assertEquals(0,f.posts.get()); + System.out.println("inventoryJobs="+jobs+" preparationMillis="+prepareMillis+" automaticMillis="+activateMillis+" totalMillis="+TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-observationsStarted)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationJobAccountingTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationJobAccountingTest.java new file mode 100644 index 000000000..18a7575d7 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationJobAccountingTest.java @@ -0,0 +1,60 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationJobAccountingTest { + ProjectMutationExecutorTest base=new ProjectMutationExecutorTest();ProjectMutationReferenceReader history;ReviewRepairWorkflow budget; + ProjectMutationPreparation.Prepared prepared;ProjectMutationJobAccounting accounting;Object project; + String id=UUID.randomUUID().toString();AtomicInteger gets=new AtomicInteger(); + @BeforeEach void setup()throws Exception{ + base.setup();project=base.root().get("_id");prepared=base.base.service.prepare(base.base.request(),()->true);assertEquals("APPLIED",base.executor().apply(prepared,"owner",()->true).state()); + history=new ProjectMutationReferenceReader(base.base.base.fixture.mongo,base.base.base.archive,base.base.service,base.executor());history.initialize(); + budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1);accounting=create(); + } + ProjectMutationJobAccounting create(){return new ProjectMutationJobAccounting(base.base.base.fixture.mongo,budget,history,base.base.base.fixture.client,1);} + @AfterEach void cleanup(){if(accounting!=null)accounting.close();if(budget!=null)budget.close();base.cleanup();} + ProjectMutationJobAccounting.Receipt check(){return accounting.check(id,project,prepared.id(),"v",()->true);} + void route(int code,String state){var f=base.base.base.fixture;f.route(exchange->{assertEquals("GET",exchange.getRequestMethod());assertTrue(exchange.getRequestURI().getPath().endsWith("/"+f.job));gets.incrementAndGet();f.reply(exchange,code,state);});} + @ParameterizedTest @ValueSource(strings={"COMPLETED","CANCELLED","RUNNING","QUEUED","EXPIRED","HELD"}) + void originalJobStateIsRetainedWithoutActivatingNewWork(String state){ + route(200,state);var before=base.root();var result=check();assertEquals("OBSERVED",result.state());assertEquals(state,result.observation().status().state()); + try(var restarted=create()){assertEquals(result,restarted.check(id,project,prepared.id(),"v",()->true));} + assertEquals(result,accounting.receipt(id,project,prepared.id(),"v",()->true));assertEquals(1,gets.get());assertEquals(before,base.root());assertEquals(0,base.base.base.fixture.posts.get()); + } + @ParameterizedTest @ValueSource(ints={404,409,401,403,429,503}) + void missingConflictingAndUnavailableJobsStayDistinct(int code){ + route(code,"QUEUED");var result=check();assertEquals(code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==503?"UNKNOWN":"UNAVAILABLE",result.observation().kind());assertEquals(result,check());assertEquals(1,gets.get()); + } + @Test void deletedProjectCannotRetargetOriginalJob(){ + base.base.base.fixture.mongo.getCollection("projects").deleteMany(new Document());route(200,"COMPLETED");assertEquals(base.base.base.fixture.job,check().observation().status().jobId());assertEquals(1,gets.get()); + } + @Test void alteredGroupReceiptOrDifferentVersionCannotCreateObservation(){ + assertThrows(RuntimeException.class,()->accounting.check(id,project,prepared.id(),"new-upload",()->true)); + assertThrows(RuntimeException.class,()->accounting.check(id,project,prepared.id(),"w",()->true)); + base.base.base.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(RuntimeException.class,this::check);assertEquals(0,base.base.base.fixture.mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).countDocuments());assertEquals(0,gets.get()); + } + @Test void lostHttpReplyIsNotRetried(){ + base.base.base.fixture.route(exchange->{gets.incrementAndGet();exchange.close();});var result=check();assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(1,gets.get()); + } + @Test void deniedPermissionAndSharedShutdownPreventRemoteObservation(){ + assertThrows(SecurityException.class,()->accounting.check(id,project,prepared.id(),"v",()->false));budget.close();assertThrows(IllegalStateException.class,this::check);assertEquals(0,gets.get()); + } + @Test void observationIdentityCannotBeReusedForAnotherGroupEvenWithSameRemoteJob(){ + route(200,"COMPLETED");check();var original=new org.bson.RawBsonDocument(base.base.base.archive.load(prepared.beforeArchiveId()).versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + base.base.base.fixture.mongo.getCollection("projects").replaceOne(new Document("_id",project),original); + var next=base.base.service.prepare(base.base.request(),()->true);assertEquals("APPLIED",base.executor().apply(next,"owner",()->true).state()); + assertThrows(RuntimeException.class,()->accounting.check(id,project,next.id(),"v",()->true));assertEquals(1,gets.get()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationObservationProgressTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationObservationProgressTest.java new file mode 100644 index 000000000..baa3cf570 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationObservationProgressTest.java @@ -0,0 +1,83 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import net.modtale.model.project.RemoteReviewBinding; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationObservationProgressTest { + static final String PROGRESS="project_mutation_observation_progress"; + ProjectMutationAutomaticAdmissionTest base=new ProjectMutationAutomaticAdmissionTest(); + AtomicInteger reads=new AtomicInteger();String secondState="COMPLETED"; + @BeforeEach void setup()throws Exception{setup(2);} + void setup(int jobs)throws Exception{ + if(jobs<2 || jobs>256)throw new IllegalArgumentException(); + base.setup();var f=base.f();var preparation=base.base.base.base.base; + var old=new RawBsonDocument(preparation.base.archive.load(base.base.base.prepared.beforeArchiveId()).versionBytes()).decode(new DocumentCodec()); + var versions=new ArrayList<>(old.getList("versions",Document.class));var first=versions.getFirst(); + for(int i=2;i<=jobs;i++){ + var added=new RawBsonDocument(VersionMutationPreparationTest.bytes(first)).decode(new DocumentCodec());added.put("_id","v"+i); + var scan=added.get("scanResult",Document.class);String request=UUID.randomUUID().toString();scan.put("scanRequestId",request); + var remote=scan.get("remoteReview",Document.class);remote.put("versionId","v"+i);remote.put("requestId",request);remote.put("jobId",UUID.randomUUID().toString());versions.add(added); + } + old.put("versions",versions);f.mongo.getCollection("projects").replaceOne(new Document("_id",old.get("_id")),old); + var proposal=preparation.request();var next=new RawBsonDocument(proposal.proposedProject()).decode(new DocumentCodec());next.getList("versions",Document.class).get(1).putAll(new Document("fileUrl",f.binding.filePath()).append("hash",f.binding.artifactSha256())); + var prepared=preparation.service.prepare(new ProjectMutationPreparation.Request(proposal.id(),proposal.projectId(),proposal.expectedSha256(),proposal.actor(),proposal.mutation(),VersionMutationPreparationTest.bytes(next)),()->true); + assertEquals("APPLIED",base.base.base.base.executor().apply(prepared,"owner",()->true).state()); + var bindings=new HashMap(); + for(var version:versions){if("w".equals(version.get("_id")))continue;var binding=f.mongo.getConverter().read(RemoteReviewBinding.class,version.get("scanResult",Document.class).get("remoteReview",Document.class));bindings.put(binding.jobId(),binding);} + f.server.removeContext("/api/v1/review-jobs");f.route(e->{ + if(e.getRequestURI().getPath().endsWith("/configuration")){var bytes=f.mapper.writeValueAsBytes(Map.of("policyVersion",f.binding.policyVersion(),"reviewConfigSha256",base.base.config));e.sendResponseHeaders(200,bytes.length);e.getResponseBody().write(bytes);return;} + var path=e.getRequestURI().getPath();var binding=bindings.get(path.substring(path.lastIndexOf('/')+1));assertNotNull(binding);reads.incrementAndGet(); + String state="v2".equals(binding.versionId())?secondState:"COMPLETED"; + var body=new LinkedHashMap();body.put("jobId",binding.jobId());body.put("requestId",binding.requestId());body.put("binding",Map.of("artifactSha256",binding.artifactSha256(),"contextSha256",binding.contextSha256(),"policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + body.put("state",state);body.put("workState",state);body.put("artifactRetained",true);body.put("createdAt",1000L);body.put("expiresAt",2000L); + byte[] bytes=f.mapper.writeValueAsBytes(body);e.sendResponseHeaders(200,bytes.length);e.getResponseBody().write(bytes); + }); + } + @AfterEach void cleanup(){base.cleanup();} + @Test void multipleJobsContinueAcrossRestartWithoutRepeatingCompletedReads()throws Exception{ + var candidate=base.candidate();assertEquals("YIELDED",base.automatic.advance(candidate,()->true).state());assertEquals(1,reads.get());assertEquals(1,base.f().mongo.getCollection(PROGRESS).countDocuments()); + Thread.sleep(150);assertEquals("ADMITTED",base.create().advance(candidate,()->true).state());assertEquals(2,reads.get());assertEquals(2,base.f().mongo.getCollection(PROGRESS).countDocuments());assertEquals(0,base.f().posts.get()); + } + @Test void lostCheckpointWriteReplyRecoversExactStoredProgress()throws Exception{ + var mongo=spy(base.f().mongo);var collection=spy(base.f().mongo.getCollection(PROGRESS)); + doReturn(collection).when(collection).withReadPreference(any());doReturn(collection).when(collection).withReadConcern(any());doReturn(collection).when(collection).withWriteConcern(any());doReturn(collection).when(collection).withTimeout(anyLong(),any()); + doReturn(collection).when(mongo).getCollection(PROGRESS);var writes=new AtomicInteger();doAnswer(call->{call.callRealMethod();writes.incrementAndGet();throw new com.mongodb.MongoException("lost checkpoint acknowledgment");}).when(collection).insertOne(any(Document.class)); + var coordinator=new ProjectMutationAutomaticAdmission(mongo,base.base.base.budget,base.base.base.history,base.base.prior,base.attempts,base.base.base.accounting,base.base.service,base.activator,base.base.base.base.base.base.archive); + var candidate=base.candidate();assertEquals("YIELDED",coordinator.advance(candidate,()->true).state());assertEquals(1,writes.get());Thread.sleep(1100); + assertEquals("ADMITTED",base.create().advance(candidate,()->true).state());assertEquals(2,reads.get());assertEquals(0,base.f().posts.get()); + } + @Test void slowReadYieldsWithinChildDeadlineAndRetainsUncertainty(){ + var f=base.f();f.server.removeContext("/api/v1/review-jobs");f.route(e->{reads.incrementAndGet();Thread.sleep(6500);e.close();}); + var client=new RemoteReviewClient(new net.modtale.config.properties.AppWardenProperties("http://127.0.0.1:"+f.server.getAddress().getPort(),"fixture-key",true,1,600),java.time.Duration.ofSeconds(20)); + try(var accounting=new ProjectMutationJobAccounting(f.mongo,base.base.base.budget,base.base.base.history,client,1)){ + var coordinator=new ProjectMutationAutomaticAdmission(f.mongo,base.base.base.budget,base.base.base.history,base.base.prior,base.attempts,accounting,base.base.service,base.activator,base.base.base.base.base.base.archive); + long started=System.nanoTime();assertEquals("WAITING",coordinator.advance(base.candidate(),()->true).state());long elapsed=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started); + assertTrue(elapsed<9000,"Child read must leave the outer work window available: "+elapsed);assertEquals(1,reads.get());assertEquals(0,f.mongo.getCollection(PROGRESS).countDocuments()); + assertEquals("UNKNOWN",f.mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).find().first().getString("state"));assertEquals(0,f.posts.get()); + } + } + @Test void onlyUnfinishedJobGetsANewObservation()throws Exception{ + secondState="RUNNING";var candidate=base.candidate();assertEquals("YIELDED",base.automatic.advance(candidate,()->true).state());Thread.sleep(1100); + assertEquals("WAITING",base.create().advance(candidate,()->true).state());assertEquals(2,reads.get());assertEquals(1,base.f().mongo.getCollection(PROGRESS).countDocuments()); + secondState="COMPLETED";Thread.sleep(1100);assertEquals("ADMITTED",base.create().advance(candidate,()->true).state());assertEquals(3,reads.get());assertEquals(0,base.f().posts.get()); + } + @ParameterizedTest @ValueSource(strings={"scope","missingReceipt","changedReceipt"}) + void progressCannotSubstituteForAuthenticatedOriginalEvidence(String change)throws Exception{ + var candidate=base.candidate();assertEquals("YIELDED",base.automatic.advance(candidate,()->true).state());var collection=base.f().mongo.getCollection(PROGRESS);var progress=collection.find().first();assertNotNull(progress); + if(change.equals("scope"))collection.updateOne(new Document("_id",progress.get("_id")),new Document("$set",new Document("scope.projectId","other"))); + else if(change.equals("missingReceipt"))base.f().mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).deleteOne(new Document("_id",progress.get("observationId"))); + else base.f().mongo.getCollection(ProjectMutationJobAccounting.COLLECTION).updateOne(new Document("_id",progress.get("observationId")),new Document("$set",new Document("observation.status.state","RUNNING").append("observation.status.workState","RUNNING"))); + Thread.sleep(1100);assertThrows(RuntimeException.class,()->base.create().advance(candidate,()->true));assertEquals(1,reads.get());assertEquals(0,base.f().posts.get());assertEquals("RUNNING",base.attempts.status(base.scope(candidate),()->true).state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationOwnerAccessTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationOwnerAccessTest.java new file mode 100644 index 000000000..70e30f38e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationOwnerAccessTest.java @@ -0,0 +1,224 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import net.modtale.service.security.access.*; +import net.modtale.service.user.account.AccountService; +import net.modtale.repository.user.*; +import net.modtale.model.user.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationOwnerAccessTest { + ProjectMutationExecutorTest base=new ProjectMutationExecutorTest();ProjectMutationOwnerAccess owner;ReviewRepairWorkflow budget;ApiKeyRepository keys;User user; + @BeforeEach void setup()throws Exception{ + base.setup();var mongo=base.base.base.fixture.mongo; + mongo.getCollection("projects").updateOne(new Document("_id",base.root().get("_id")),new Document("$set",new Document("authorId","owner").append("status","PUBLISHED"))); + user=new User();user.setId("owner");var accounts=mock(AccountService.class);when(accounts.getCurrentUser(any())).thenReturn(user); + var access=new AccessControlService(accounts,mock(UserRepository.class),mock(PermissionProjectLookupService.class),mongo); + keys=new MongoRepositoryFactory(mongo).getRepository(ApiKeyRepository.class); + var authority=new ProjectMutationOwnerAuthority(accounts,access,keys,mongo);var reader=new ProjectMutationReferenceReader(mongo,base.base.base.archive,base.base.service,base.executor());reader.initialize(); + budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1); + owner=new ProjectMutationOwnerAccess(budget,authority,base.base.service,base.executor(),reader); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,List.of())); + } + @AfterEach void cleanup(){SecurityContextHolder.clearContext();RequestContextHolder.resetRequestAttributes();if(budget!=null)budget.close();base.cleanup();} + ProjectMutationPreparation.Prepared prepare(){var r=base.base.request();return owner.prepare(r.id(),r.projectId(),r.expectedSha256(),r.mutation(),r.proposedProject());} + void api(Set scopes){ + String plain="md_owner-access-fixture";var key=new ApiKey("owner","fixture",new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder().encode(plain),plain.substring(0,10)); + key.setId("key");key.setContextPermissions(Map.of("PERSONAL",scopes));keys.insert(key); + var request=new MockHttpServletRequest();request.addHeader("X-MODTALE-KEY",plain);RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,List.of(new SimpleGrantedAuthority("ROLE_API")))); + } + @Test void actualOwnerBoundarySignsAndAppliesGroupWithServerAttributedActor(){ + var prepared=prepare();assertEquals("APPLIED",owner.apply(prepared).state()); + assertEquals("owner",base.base.base.archive.load(prepared.id()).actorId());assertEquals(0,budget.status().active()); + } + @Test void replacementRequiresBothCreateAndDeleteScopes(){ + api(Set.of(ApiKey.ApiPermission.VERSION_CREATE));assertThrows(SecurityException.class,this::prepare); + assertEquals(0,base.base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).countDocuments()); + assertEquals(0,budget.status().active()); + } + @Test void revokedKeyBetweenPreparationAndApplicationCannotApply(){ + api(Set.of(ApiKey.ApiPermission.VERSION_CREATE,ApiKey.ApiPermission.VERSION_DELETE));var prepared=prepare();var before=base.root();keys.deleteById("key"); + assertThrows(SecurityException.class,()->owner.apply(prepared));assertEquals(before,base.root()); + } + @Test void ownerChangeBetweenPreparationAndApplicationCannotApply(){ + var prepared=prepare();base.base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",base.root().get("_id")),new Document("$set",new Document("authorId","other"))); + assertThrows(SecurityException.class,()->owner.apply(prepared));assertEquals(2,base.root().getList("versions",Document.class).size()); + } + net.modtale.service.project.version.VersionService deletionService(boolean retained, + net.modtale.service.project.lifecycle.ProjectDeletionService files,net.modtale.service.project.query.ProjectService projects){ + var mongo=base.base.base.fixture.mongo;var access=mock(net.modtale.service.project.access.ProjectAccessService.class); + when(access.requireVersionPermission(anyString(),any(),eq("VERSION_DELETE"),anyString())).thenAnswer(call->mongo.findById(call.getArgument(0),net.modtale.model.project.Project.class)); + var versions=mock(net.modtale.service.project.access.ProjectVersionAccessService.class); + when(versions.requireById(any(),anyString(),any())).thenAnswer(call->{var project=(net.modtale.model.project.Project)call.getArgument(0);return project.getVersions().stream().filter(v->v.getId().equals(call.getArgument(1))).findFirst().orElseThrow();}); + var service=new net.modtale.service.project.version.VersionService(new ProjectReviewPersistence(mongo),projects,access, + new net.modtale.service.project.access.ProjectMutationGuard(),versions,mongo,mock(net.modtale.service.project.version.VersionManifestService.class),files, + mock(net.modtale.service.project.version.VersionCreationCommandHandler.class),mock(net.modtale.service.project.version.VersionUpdateCommandHandler.class)); + if(retained)org.springframework.test.util.ReflectionTestUtils.setField(service,"retainedMutations",owner);return service; + } + @Test void productionDeletionServiceRetainsOriginalRemoteHistoryAndArtifact(){ + var files=mock(net.modtale.service.project.lifecycle.ProjectDeletionService.class);var projects=mock(net.modtale.service.project.query.ProjectService.class); + Object id=base.root().get("_id");var original=VersionMutationPreparationTest.bytes(base.root()); + deletionService(true,files,projects).deleteVersion(id.toString(),"v",user); + assertEquals(List.of("w"),base.root().getList("versions",Document.class).stream().map(v->v.getString("_id")).toList()); + var reader=new ProjectMutationReferenceReader(base.base.base.fixture.mongo,base.base.base.archive,base.base.service,base.executor()); + var page=reader.page(id,null,64,()->true);assertEquals(1,page.operationIds().size()); + assertArrayEquals(original,reader.read(id,page.operationIds().getFirst(),()->true).evidence().before().versionBytes()); + verifyNoInteractions(files);verify(projects).evictProjectCache(any(net.modtale.model.project.Project.class)); + assertEquals(0,base.base.base.fixture.posts.get());assertEquals(0,base.base.base.fixture.gets.get()); + } + @Test void disabledRuntimeCannotDeleteVersionWithLiveReviewBinding(){ + var files=mock(net.modtale.service.project.lifecycle.ProjectDeletionService.class);var projects=mock(net.modtale.service.project.query.ProjectService.class);var before=base.root(); + assertThrows(net.modtale.exception.InvalidVersionRequestException.class,()->deletionService(false,files,projects).deleteVersion(before.get("_id").toString(),"v",user)); + assertEquals(before,base.root());verifyNoInteractions(files,projects); + } + + net.modtale.service.project.version.VersionUpdateCommandHandler editHandler(boolean retained, + net.modtale.service.project.version.VersionMutationOrchestrationService orchestration){ + var mongo=base.base.base.fixture.mongo;var access=mock(net.modtale.service.project.access.ProjectAccessService.class); + when(access.requireVersionPermission(anyString(),any(),eq("VERSION_EDIT"),anyString())).thenAnswer(call->mongo.findById(call.getArgument(0),net.modtale.model.project.Project.class)); + var versions=mock(net.modtale.service.project.access.ProjectVersionAccessService.class); + when(versions.requireById(any(),anyString(),any())).thenAnswer(call->{var project=(net.modtale.model.project.Project)call.getArgument(0);return project.getVersions().stream().filter(v->v.getId().equals(call.getArgument(1))).findFirst().orElseThrow();}); + when(orchestration.sanitizeChangelog(anyString())).thenAnswer(call->call.getArgument(0)); + var handler=new net.modtale.service.project.version.VersionUpdateCommandHandler(new ProjectReviewPersistence(mongo),mock(net.modtale.service.project.query.ProjectService.class),access, + new net.modtale.service.project.access.ProjectMutationGuard(),versions,orchestration); + if(retained)org.springframework.test.util.ReflectionTestUtils.setField(handler,"retainedMutations",owner);return handler; + } + @Test void productionContextEditRetainsOldJobAndPreservesBlock(){ + base.base.base.fixture.change("scanResult.verdict","BLOCK");var before=VersionMutationPreparationTest.bytes(base.root()); + var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + editHandler(true,orchestration).updateVersion(base.root().get("_id").toString(),"v",null,null,List.of("changed-runtime"),null,null,user); + var version=base.root().getList("versions",Document.class).getFirst();var scan=version.get("scanResult",Document.class); + assertEquals("MUTATION_HELD",scan.get("scanState"));assertEquals(2,scan.get("scanAttempt"));assertEquals("BLOCK",scan.get("verdict"));assertNotNull(version.get("replacementSecurityHold")); + var ref=base.base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).find().first(); + assertArrayEquals(before,base.base.base.archive.load(ref.getString("beforeArchiveId")).versionBytes()); + verify(orchestration,never()).enqueueContextChangeScan(any(),any());assertEquals(0,base.base.base.fixture.posts.get()); + } + @Test void metadataOnlyEditKeepsExistingReviewWithoutRetainedMutation(){ + var scan=base.root().getList("versions",Document.class).getFirst().get("scanResult");var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + editHandler(true,orchestration).updateVersion(base.root().get("_id").toString(),"v",null,null,null,"New changelog",null,user); + var version=base.root().getList("versions",Document.class).getFirst();assertEquals(scan,version.get("scanResult"));assertEquals("New changelog",version.get("changelog")); + assertEquals(0,base.base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).countDocuments());verify(orchestration,never()).enqueueContextChangeScan(any(),any()); + } + @Test void disabledRuntimeCannotOverwriteTrackedContext(){ + var before=base.root();var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + assertThrows(net.modtale.exception.InvalidVersionRequestException.class,()->editHandler(false,orchestration).updateVersion(before.get("_id").toString(),"v",null,null,List.of("changed"),null,null,user)); + assertEquals(before,base.root());verify(orchestration,never()).enqueueContextChangeScan(any(),any()); + } + @Test void modpackContextEditInvalidatesCacheWithoutDeletingHistoricalArchive(){ + var mongo=base.base.base.fixture.mongo;mongo.getCollection("projects").updateOne(new Document("_id",base.root().get("_id")),new Document("$set",new Document("classification","MODPACK"))); + var dependency=new net.modtale.model.project.ProjectDependency("dependency","Original dependency","1.0"); + base.base.base.fixture.change("dependencies",List.of(mongo.getConverter().convertToMongoType(dependency))); + var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + when(orchestration.resolveRequestedDependencies(any(),eq(true),eq(true))).thenReturn(new net.modtale.service.project.version.VersionDependencyService.ResolvedDependencies(List.of(),List.of())); + editHandler(true,orchestration).updateVersion(base.root().get("_id").toString(),"v",List.of(),null,null,null,null,user); + var version=base.root().getList("versions",Document.class).getFirst();assertNull(version.get("fileUrl"));assertEquals("MUTATION_HELD",version.get("scanResult",Document.class).get("scanState")); + verify(orchestration,never()).deleteCachedArtifact(anyString()); + } + + net.modtale.service.project.lifecycle.ProjectDraftWorkflowService submissionService(boolean retained, + net.modtale.service.project.version.VersionMutationOrchestrationService orchestration,net.modtale.service.communication.WebhookService webhooks){ + var mongo=base.base.base.fixture.mongo;var projects=mock(net.modtale.repository.project.ProjectRepository.class); + when(projects.findById(anyString())).thenAnswer(call->Optional.ofNullable(mongo.findById(call.getArgument(0),net.modtale.model.project.Project.class))); + var access=mock(net.modtale.service.project.access.ProjectAccessService.class); + when(access.requireProjectPermission(anyString(),any(),eq("PROJECT_STATUS_SUBMIT"),anyString())).thenAnswer(call->mongo.findById(call.getArgument(0),net.modtale.model.project.Project.class)); + var service=new net.modtale.service.project.lifecycle.ProjectDraftWorkflowService(projects,mock(net.modtale.service.project.query.ProjectService.class), + mock(net.modtale.service.project.validation.ValidationService.class),webhooks,mock(net.modtale.service.security.validation.SanitizationService.class),mock(UserRepository.class),access, + new net.modtale.service.project.access.ProjectMutationGuard(),orchestration,new net.modtale.config.properties.AppLimitProperties(10,5,10,5,5,5,20,10),new ProjectReviewPersistence(mongo)); + if(retained)org.springframework.test.util.ReflectionTestUtils.setField(service,"retainedMutations",owner);return service; + } + void draft(){ + user.setEmailVerified(true);base.base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",base.root().get("_id")), + new Document("$set",new Document("status","DRAFT").append("description","A complete project description").append("tags",List.of("utility")).append("license","MIT"))); + } + @Test void productionSubmissionPreservesPrunedHistoryAndAttemptWithOnlySubmissionScope(){ + draft();api(Set.of(ApiKey.ApiPermission.PROJECT_STATUS_SUBMIT));var fixture=base.base.base.fixture; + fixture.change("retainedRemoteReview",fixture.mongo.getConverter().convertToMongoType(fixture.saved().getRemoteReview()));fixture.change("scanResult",null); + var before=VersionMutationPreparationTest.bytes(base.root());var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class);var webhooks=mock(net.modtale.service.communication.WebhookService.class); + submissionService(true,orchestration,webhooks).submitProject(base.root().get("_id").toString(),user); + assertEquals("PENDING",base.root().get("status"));var version=base.root().getList("versions",Document.class).getFirst(); + assertEquals(2,version.get("scanResult",Document.class).get("scanAttempt"));assertEquals("MUTATION_HELD",version.get("scanResult",Document.class).get("scanState")); + var ref=fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).find().first();assertArrayEquals(before,base.base.base.archive.load(ref.getString("beforeArchiveId")).versionBytes()); + verifyNoInteractions(orchestration,webhooks); + } + @Test void disabledRuntimeCannotSubmitTrackedVersions(){ + draft();var before=base.root();var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class);var webhooks=mock(net.modtale.service.communication.WebhookService.class); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class,()->submissionService(false,orchestration,webhooks).submitProject(before.get("_id").toString(),user)); + assertEquals(before,base.root());verifyNoInteractions(orchestration,webhooks); + } + @Test void emailVerificationLossPreventsPreparedSubmission(){ + draft();var captured=base.base.service.capture(base.root().get("_id"),()->true);var proposed=base.root();proposed.put("status","PENDING"); + var prepared=owner.prepare(UUID.randomUUID().toString(),captured.projectId(),captured.sha256(),ProjectMutationPreparation.Mutation.SUBMISSION,VersionMutationPreparationTest.bytes(proposed)); + user.setEmailVerified(false);assertThrows(SecurityException.class,()->owner.apply(prepared));assertEquals("DRAFT",base.root().get("status")); + } + @Test void emptyModpackSubmissionStillNotifiesManualReview(){ + draft();base.base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",base.root().get("_id")),new Document("$set",new Document("classification","MODPACK").append("versions",List.of()))); + var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class);var webhooks=mock(net.modtale.service.communication.WebhookService.class); + submissionService(true,orchestration,webhooks).submitProject(base.root().get("_id").toString(),user); + assertEquals("PENDING",base.root().get("status"));verify(webhooks).triggerAdminNewProjectWebhook(any());verifyNoInteractions(orchestration); + } + + @Test void legacyUnboundQueuedSubmissionGetsRetainedRequest(){ + draft();String old=UUID.randomUUID().toString();base.base.base.fixture.change("reviewStatus","PENDING"); + base.base.base.fixture.change("scanResult",new Document("status","SCANNING").append("scanState","QUEUED").append("scanAttempt",1).append("scanRequestId",old)); + owner.submitDraft(base.root());var scan=base.root().getList("versions",Document.class).getFirst().get("scanResult",Document.class); + assertEquals("MUTATION_HELD",scan.get("scanState"));assertEquals(2,scan.get("scanAttempt"));assertNotEquals(old,scan.get("scanRequestId")); + } + @Test void submissionDoesNotReplaceAnAlreadyRetainedHeldRequest(){ + draft();var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + editHandler(true,orchestration).updateVersion(base.root().get("_id").toString(),"v",null,null,List.of("changed"),null,null,user); + var scan=base.root().getList("versions",Document.class).getFirst().get("scanResult");owner.submitDraft(base.root()); + assertEquals(scan,base.root().getList("versions",Document.class).getFirst().get("scanResult")); + } + + net.modtale.service.project.version.VersionCreationCommandHandler uploadHandler(boolean retained, + net.modtale.service.project.version.VersionMutationOrchestrationService orchestration){ + var mongo=base.base.base.fixture.mongo;var access=mock(net.modtale.service.project.access.ProjectAccessService.class); + when(access.requireVersionPermission(anyString(),any(),eq("VERSION_CREATE"),anyString())).thenAnswer(call->mongo.findById(call.getArgument(0),net.modtale.model.project.Project.class)); + var handler=new net.modtale.service.project.version.VersionCreationCommandHandler(new ProjectReviewPersistence(mongo),mock(net.modtale.service.project.query.ProjectService.class),access, + new net.modtale.service.project.access.ProjectMutationGuard(),orchestration,new net.modtale.config.properties.AppLimitProperties(10,5,10,5,5,5,20,10)); + if(retained)org.springframework.test.util.ReflectionTestUtils.setField(handler,"retainedMutations",owner);return handler; + } + void uploadFixture(){ + base.base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",base.root().get("_id")),new Document("$set",new Document("classification","DATA"))); + base.base.base.fixture.change("versionNumber","1.0");base.base.base.fixture.change("gameVersions",List.of("a","b")); + } + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void productionUploadRetainsFullAndPartialReplacementHistory(boolean partial){ + uploadFixture();base.base.base.fixture.change("scanResult.verdict","BLOCK");var before=VersionMutationPreparationTest.bytes(base.root()); + var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + when(orchestration.prepareVersionArtifact(any(),any())).thenReturn(new net.modtale.service.project.version.VersionArtifactService.PreparedVersionArtifact(net.modtale.model.project.ProjectClassification.DATA,"new.zip","d".repeat(64))); + uploadHandler(true,orchestration).addVersion(base.root().get("_id").toString(),"1.0",partial?List.of("a"):List.of("a","b"), + new org.springframework.mock.web.MockMultipartFile("file","new.zip","application/zip",new byte[]{1}),null,null,null,null,true,user); + var versions=base.root().getList("versions",Document.class);assertEquals(partial?3:2,versions.size());assertEquals("new.zip",versions.getFirst().get("fileUrl")); + assertEquals("MUTATION_HELD",versions.getFirst().get("scanResult",Document.class).get("scanState")); + if(partial){var old=versions.stream().filter(v->"v".equals(v.get("_id"))).findFirst().orElseThrow();assertEquals(List.of("b"),old.get("gameVersions"));assertEquals("BLOCK",old.get("scanResult",Document.class).get("verdict"));assertEquals(2,old.get("scanResult",Document.class).get("scanAttempt"));} + var ref=base.base.base.fixture.mongo.getCollection(ProjectMutationExecutor.REFERENCES).find().first();assertArrayEquals(before,base.base.base.archive.load(ref.getString("beforeArchiveId")).versionBytes()); + verify(orchestration,never()).enqueueInitialScan(any(),any(),any(),anyBoolean(),any());verify(orchestration,never()).enqueueContextChangeScan(any(),any());verify(orchestration,never()).deleteVersionFile(any()); + } + @Test void disabledRuntimeRejectsTrackedReplacementBeforeUpload(){ + uploadFixture();var before=base.root();var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + assertThrows(net.modtale.exception.InvalidVersionRequestException.class,()->uploadHandler(false,orchestration).addVersion(before.get("_id").toString(),"1.0",List.of("a"),null,null,null,null,null,true,user)); + assertEquals(before,base.root());verify(orchestration,never()).prepareVersionArtifact(any(),any()); + } + @Test void validatedUploadCanChangeDataProjectToPlugin(){ + uploadFixture();var orchestration=mock(net.modtale.service.project.version.VersionMutationOrchestrationService.class); + when(orchestration.prepareVersionArtifact(any(),any())).thenAnswer(call->{((net.modtale.model.project.Project)call.getArgument(0)).setClassification(net.modtale.model.project.ProjectClassification.PLUGIN); + return new net.modtale.service.project.version.VersionArtifactService.PreparedVersionArtifact(net.modtale.model.project.ProjectClassification.PLUGIN,"new.jar","d".repeat(64));}); + uploadHandler(true,orchestration).addVersion(base.root().get("_id").toString(),"2.0",List.of("a"),new org.springframework.mock.web.MockMultipartFile("file","new.jar","application/java-archive",new byte[]{1}),null,null,null,null,false,user); + assertEquals("PLUGIN",base.root().get("classification"));assertEquals("new.jar",base.root().getList("versions",Document.class).getFirst().get("fileUrl")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationOwnerAuthorityTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationOwnerAuthorityTest.java new file mode 100644 index 000000000..98220c5a9 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationOwnerAuthorityTest.java @@ -0,0 +1,76 @@ +package net.modtale.service.security.scan; + +import com.mongodb.client.*; +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.model.user.*; +import net.modtale.repository.user.*; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.access.*; +import net.modtale.service.user.account.AccountService; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ProjectMutationOwnerAuthorityTest { + MongoClient client;MongoTemplate mongo;ApiKeyRepository keys;ProjectMutationOwnerAuthority authority;User user;AccountService accounts; + static final ApiKey.ApiPermission EDIT=ApiKey.ApiPermission.VERSION_EDIT; + @BeforeEach void setup(){ + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");mongo=new MongoTemplate(client,"warden_owner_"+UUID.randomUUID().toString().replace("-","")); + keys=new MongoRepositoryFactory(mongo).getRepository(ApiKeyRepository.class);accounts=mock(AccountService.class); + user=new User();user.setId("owner");when(accounts.getCurrentUser(any())).thenAnswer(call->user); + var project=new Project();project.setId("project");project.setAuthorId("owner");project.setStatus(ProjectStatus.PUBLISHED);mongo.insert(project); + var access=new AccessControlService(accounts,mock(UserRepository.class),mock(PermissionProjectLookupService.class),mongo); + authority=new ProjectMutationOwnerAuthority(accounts,access,keys,mongo);authenticate(false); + } + void authenticate(boolean api){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,api?List.of(new SimpleGrantedAuthority("ROLE_API")):List.of()));} + void key(Set permissions){ + String plain="md_authority-fixture";var key=new ApiKey("owner","fixture",new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder().encode(plain),plain.substring(0,10)); + key.setId("key");key.setContextPermissions(Map.of("project",permissions));keys.insert(key); + var request=new MockHttpServletRequest();request.addHeader("X-MODTALE-KEY",plain);RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));authenticate(true); + } + @AfterEach void cleanup(){SecurityContextHolder.clearContext();RequestContextHolder.resetRequestAttributes();if(client!=null){mongo.getDb().drop();client.close();}} + @Test void browserOwnerIsBoundToCurrentIdentityAndProject(){ + var bound=authority.bind("project",Set.of(EDIT));assertEquals("owner",bound.actor());assertTrue(bound.permitted().getAsBoolean()); + mongo.getCollection("projects").updateOne(new Document("_id","project"),new Document("$set",new Document("authorId","other"))); + assertFalse(bound.permitted().getAsBoolean()); + } + @ParameterizedTest @ValueSource(strings={"revoked","scope","credential","owner","membership","deletedAccount","authentication"}) + void keyAuthorityRechecksEveryMutableGrant(String change){ + key(Set.of(EDIT));var bound=authority.bind("project",Set.of(EDIT));assertTrue(bound.permitted().getAsBoolean()); + switch(change){ + case "revoked" -> keys.deleteById("key"); + case "scope" -> mongo.getCollection("api_keys").updateOne(new Document("_id","key"),new Document("$set",new Document("contextPermissions",new Document()))); + case "credential" -> mongo.getCollection("api_keys").updateOne(new Document("_id","key"),new Document("$set",new Document("keyHash","changed"))); + case "owner" -> mongo.getCollection("api_keys").updateOne(new Document("_id","key"),new Document("$set",new Document("userId","other"))); + case "membership" -> mongo.getCollection("projects").updateOne(new Document("_id","project"),new Document("$set",new Document("authorId","other"))); + case "deletedAccount" -> when(accounts.getCurrentUser(any())).thenReturn(null); + case "authentication" -> authenticate(true); + } + assertFalse(bound.permitted().getAsBoolean()); + } + @Test void keyScopeDoesNotReplaceMembershipAndOwnerDoesNotReplaceKeyScope(){ + key(Set.of(ApiKey.ApiPermission.VERSION_READ));assertThrows(SecurityException.class,()->authority.bind("project",Set.of(EDIT))); + } + @Test void authenticationWithoutRequestKeyCannotBorrowOwnerAuthority(){ + authenticate(true);assertThrows(SecurityException.class,()->authority.bind("project",Set.of(EDIT))); + } + @Test void repairPermissionAndDeletedProjectsCannotBeUsedForOwnerMutations(){ + assertThrows(SecurityException.class,()->authority.bind("project",Set.of(ApiKey.ApiPermission.PROJECT_DELETE))); + mongo.getCollection("projects").updateOne(new Document("_id","project"),new Document("$set",new Document("status","DELETED"))); + assertThrows(SecurityException.class,()->authority.bind("project",Set.of(EDIT))); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationParallelHistoryTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationParallelHistoryTest.java new file mode 100644 index 000000000..5a0849f6e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationParallelHistoryTest.java @@ -0,0 +1,75 @@ +package net.modtale.service.security.scan; + +import com.mongodb.WriteConcern; +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationParallelHistoryTest { + ProjectMutationHistoryBudgetTest first=new ProjectMutationHistoryBudgetTest(),second=new ProjectMutationHistoryBudgetTest(); + @BeforeEach void setup()throws Exception{first.setup();second.base.f().project="1234567890abcdef12345678";second.setup();} + @AfterEach void cleanup(){try{second.cleanup();}finally{first.cleanup();}} + record Pipeline(ProjectMutationJobAccounting accounting,ProjectMutationAutomaticAdmission automatic) implements AutoCloseable {public void close(){accounting.close();}} + Pipeline pipeline(MongoTemplate mongo,ReviewRepairWorkflow budget,ReviewSnapshotArchive archive,RemoteReviewClient client){ + var groupPreparation=new ProjectMutationPreparation(mongo,archive,Clock.systemUTC(),60000);var journal=new ReviewRepairJournal(mongo,archive); + var groupExecutor=new ProjectMutationExecutor(mongo,groupPreparation,archive,journal);var history=new ProjectMutationReferenceReader(mongo,archive,groupPreparation,groupExecutor);history.initialize(); + var prior=new ProjectMutationPriorWorkReader(mongo,budget,history);var accounting=new ProjectMutationJobAccounting(mongo,budget,history,client,2); + var preparation=new ProjectMutationAdmissionPreparation(mongo,budget,archive,history,prior,accounting,client,Clock.systemUTC(),60000); + var activator=new ProjectMutationActivator(mongo,budget,preparation,archive,journal); + return new Pipeline(accounting,new ProjectMutationAutomaticAdmission(mongo,budget,history,prior,new ProjectMutationAdmissionAttempts(mongo,budget),accounting,preparation,activator,archive)); + } + @RepeatedTest(5) void largeIndependentProjectsShareCapacityWithoutLosingEvidence()throws Exception{ + long firstBytes=first.stage(2790000,8),secondBytes=second.stage(2790000,8);assertTrue(firstBytes>60L*1024*1024 && secondBytes>60L*1024*1024); + var a=first.base.f();var b=second.base.f();var left=first.base.candidate();var right=second.base.candidate();assertNotEquals(left.projectId(),right.projectId()); + var expected=Map.of(left.projectId(),first.base.base.prior.read(left.projectId(),left.mutationId(),()->true), + right.projectId(),second.base.base.prior.read(right.projectId(),right.mutationId(),()->true)); + // Both fixtures use the same isolated signing key; copy their complete, still-authenticated records into one test database. + for(String name:b.mongo.getCollectionNames()){ + var target=a.mongo.getCollection(name).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + try(var rows=b.mongo.getCollection(name).find().batchSize(16).iterator()){while(rows.hasNext())target.insertOne(rows.next());} + } + var entered=new CountDownLatch(2);var release=new CountDownLatch(1);var configurations=new AtomicInteger(); + var clientA=spy(a.client);var clientB=spy(b.client); + for(var client:List.of(clientA,clientB))doAnswer(call->{if(configurations.getAndIncrement()<2){entered.countDown();assertTrue(release.await(15,TimeUnit.SECONDS));}return call.callRealMethod();}).when(client).configuration(any(),any()); + try(var budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),2); + var p=pipeline(a.mongo,budget,first.base.base.base.base.base.base.archive,clientA); + var q=pipeline(a.mongo,budget,first.base.base.base.base.base.base.archive,clientB); + var workers=Executors.newFixedThreadPool(2)){ + long started=System.nanoTime();var leftResult=workers.submit(()->p.automatic().advance(left,()->true));var rightResult=workers.submit(()->q.automatic().advance(right,()->true)); + try{assertTrue(entered.await(20,TimeUnit.SECONDS));assertEquals(2,budget.status().active()); + assertEquals("Review repair is busy",assertThrows(IllegalStateException.class,()->p.automatic().advance(left,()->true)).getMessage()); + assertEquals(2,a.mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).countDocuments()); + }finally{release.countDown();} + var leftOutcome=leftResult.get(30,TimeUnit.SECONDS);var rightOutcome=rightResult.get(30,TimeUnit.SECONDS); + assertEquals("ADMITTED",leftOutcome.state(),()->"left="+leftOutcome+" right="+rightOutcome+" journals="+a.mongo.getCollection(ReviewRepairJournal.COLLECTION).find().projection(new Document("state",1).append("action",1)).into(new ArrayList<>())); + assertEquals("ADMITTED",rightOutcome.state());assertEquals(0,budget.status().active()); + var records=a.mongo.getCollection(ProjectMutationActivator.ADMISSIONS).find().into(new ArrayList());assertEquals(2,records.size()); + assertEquals(Set.of(left.projectId(),right.projectId()),records.stream().map(d->d.get("projectId")).collect(java.util.stream.Collectors.toSet())); + for(var record:records){var evidence=first.base.base.base.base.base.base.archive.load(record.getString("decisionId"));assertEquals(record.get("projectId"),evidence.projectId()); + var payload=new org.bson.RawBsonDocument(evidence.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + var inventory=expected.get(record.get("projectId")); + assertEquals(inventory.groups(),payload.getList("groups",String.class)); + var items=payload.getList("evidence",Document.class);assertEquals(inventory.work().size(),items.size()); + var observations=payload.get("observations",Document.class);assertEquals(1,observations.size()); + for(int i=0;itrue);var next=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec());next.put("versions",List.of(next.getList("versions",Document.class).get(1),new Document("_id","new-upload").append("reviewStatus","PENDING")));return new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),captured.projectId(),captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(next));} + @Test void wholeGroupRetainsRemovedAddedAndMovedVersionsWithoutMutation(){ + var request=request();var before=base.raw();var prepared=service.prepare(request,()->true);assertEquals(prepared,service.prepare(request,()->true));var recovered=service.recover(prepared.id(),"owner",()->true); + assertArrayEquals(service.capture(request.projectId(),()->true).bytes(),recovered.before().versionBytes());assertArrayEquals(request.proposedProject(),recovered.after().versionBytes()); + assertEquals(3,recovered.transitions().size());assertTrue(recovered.transitions().stream().anyMatch(t->t.changes().contains(VersionReviewTransition.Change.REMOVED))); + assertTrue(recovered.transitions().stream().anyMatch(t->t.changes().contains(VersionReviewTransition.Change.ADDED)));assertEquals(before,base.raw());assertEquals(0,base.fixture.posts.get()); + } + @ParameterizedTest @ValueSource(strings={"actor","subset","rootAuthority"}) + void proposalCannotBeRetargetedOrSubsetted(String change){ + var request=request();var prepared=service.prepare(request,()->true);var next=new RawBsonDocument(request.proposedProject()).decode(new DocumentCodec()); + if(change.equals("subset"))next.put("versions",List.of(next.getList("versions",Document.class).getFirst()));if(change.equals("rootAuthority"))next.put("authorId","different-owner"); + var retry=new ProjectMutationPreparation.Request(request.id(),request.projectId(),request.expectedSha256(),change.equals("actor")?"another":request.actor(),request.mutation(),VersionMutationPreparationTest.bytes(next)); + assertThrows(RuntimeException.class,()->service.prepare(retry,()->true));assertEquals(prepared,service.recover(prepared.id(),"owner",()->true).prepared()); + } + @Test void ownershipChangesAreRejectedBeforeRetention(){var request=request();var next=new RawBsonDocument(request.proposedProject()).decode(new DocumentCodec());next.put("authorId","different");assertThrows(IllegalStateException.class,()->service.prepare(new ProjectMutationPreparation.Request(request.id(),request.projectId(),request.expectedSha256(),request.actor(),request.mutation(),VersionMutationPreparationTest.bytes(next)),()->true));assertEquals(0,base.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());} + @Test void submissionBindsLifecycleAndTheCompleteVersionSet(){ + var id=base.raw().get("_id");base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",id),new Document("$set",new Document("status","DRAFT"))); + var captured=service.capture(id,()->true);var next=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec());next.put("status","PENDING"); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),id,captured.sha256(),"owner",ProjectMutationPreparation.Mutation.SUBMISSION,VersionMutationPreparationTest.bytes(next)); + var prepared=service.prepare(request,()->true);assertEquals(2,service.recover(prepared.id(),"owner",()->true).transitions().size()); + next.put("versions",List.of(next.getList("versions",Document.class).getFirst())); + assertThrows(IllegalStateException.class,()->service.prepare(new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),id,captured.sha256(),"owner",request.mutation(),VersionMutationPreparationTest.bytes(next)),()->true)); + } + @Test void rootChangeAfterRetentionPreventsSuccessfulPreparation(){var request=request();var archive=spy(base.archive);doAnswer(call->{var result=call.callRealMethod();base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",request.projectId()),new Document("$set",new Document("authorId","changed")));return result;}).when(archive).retain(any());assertThrows(IllegalStateException.class,()->create(archive,clock).prepare(request,()->true));} + @Test void historyRecoversAfterDeletionButExpiredPreparationCannotRestart(){var request=request();var prepared=service.prepare(request,()->true);var later=create(base.archive,Clock.offset(clock,Duration.ofMinutes(2)));assertThrows(IllegalStateException.class,()->later.prepare(request,()->true));base.fixture.mongo.getCollection("projects").deleteMany(new Document());assertEquals(prepared,later.recover(prepared.id(),"owner",()->true).prepared());assertThrows(SecurityException.class,()->later.recover(prepared.id(),"owner",()->false));} + @ParameterizedTest @ValueSource(strings={"BEFORE","AFTER","INTENT"}) + void lostAcknowledgementRecoversOnlyTheSameWholeGroup(String part){var archive=spy(base.archive);var once=new java.util.concurrent.atomic.AtomicBoolean();doAnswer(call->{var result=(ReviewSnapshotArchive.Snapshot)call.callRealMethod();if(result.action().name().endsWith(part)&&!once.getAndSet(true))throw new IllegalStateException("lost reply");return result;}).when(archive).retain(any());var request=request();var prepared=create(archive,clock).prepare(request,()->true);assertTrue(once.get());assertEquals(prepared,service.prepare(request,()->true));} + @Test void tamperedGroupPartFailsRecovery(){var prepared=service.prepare(request(),()->true);base.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",prepared.afterArchiveId()),new Document("$set",new Document("projectId","other")));assertThrows(IllegalStateException.class,()->service.recover(prepared.id(),"owner",()->true));} + @Test void duplicateVersionIdsAndCrossProjectRootsCannotBeSigned(){ + var r=request();var next=new RawBsonDocument(r.proposedProject()).decode(new DocumentCodec());var first=next.getList("versions",Document.class).getFirst();next.put("versions",List.of(first,first));var duplicate=next; + assertThrows(IllegalArgumentException.class,()->service.prepare(new ProjectMutationPreparation.Request(r.id(),r.projectId(),r.expectedSha256(),r.actor(),r.mutation(),VersionMutationPreparationTest.bytes(duplicate)),()->true)); + next=new RawBsonDocument(r.proposedProject()).decode(new DocumentCodec());next.put("_id","different-project");var cross=VersionMutationPreparationTest.bytes(next); + assertThrows(IllegalStateException.class,()->service.prepare(new ProjectMutationPreparation.Request(r.id(),r.projectId(),r.expectedSha256(),r.actor(),r.mutation(),cross),()->true)); + } + @Test void captureAndRequestDoNotExposeMutableSnapshotBytes(){ + var r=request();var captured=service.capture(r.projectId(),()->true);var data=captured.bytes();data[0]=0;assertNotEquals(0,captured.bytes()[0]); + var proposed=r.proposedProject();proposed[0]=0;assertNotEquals(0,r.proposedProject()[0]); + assertThrows(SecurityException.class,()->service.capture(r.projectId(),()->false)); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationPriorWorkReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationPriorWorkReaderTest.java new file mode 100644 index 000000000..00e5a1b8f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationPriorWorkReaderTest.java @@ -0,0 +1,76 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.util.function.Consumer; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationPriorWorkReaderTest { + ProjectMutationJobAccountingTest base=new ProjectMutationJobAccountingTest();ProjectMutationPriorWorkReader reader; + @BeforeEach void setup()throws Exception{base.setup();reader=new ProjectMutationPriorWorkReader(base.base.base.base.fixture.mongo,base.budget,base.history);} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationPriorWorkReader.Inventory read(String id){return reader.read(base.project,id,()->true);} + @Test void replacementIncludesRemovedOriginalJobAndNewVersionButNotUnchangedSibling(){ + var current=base.base.root();var result=read(base.prepared.id());assertEquals(List.of(base.prepared.id()),result.groups());assertEquals(2,result.work().size()); + var old=result.work().stream().filter(w->w.versionId().equals("v")).findFirst().orElseThrow();assertEquals(ProjectMutationPriorWorkReader.Kind.REMOTE_JOB,old.kind());assertEquals(base.base.base.base.fixture.job,old.binding().jobId()); + var fresh=result.work().stream().filter(w->w.versionId().equals("new-upload")).findFirst().orElseThrow();assertEquals(ProjectMutationPriorWorkReader.Kind.NEW_VERSION,fresh.kind());assertNull(fresh.binding()); + assertEquals(current,base.base.root());assertEquals(0,base.base.base.base.fixture.posts.get()); + } + @ParameterizedTest @ValueSource(strings={"missing","jobMissing","wrongArtifact","malformed"}) + void unusableOriginalIdentityIsUnresolvedRatherThanNoPriorWork(String type){ + var id=restage(v->{var scan=v.get("scanResult",Document.class);var remote=scan.get("remoteReview",Document.class); + switch(type){case "missing"->v.remove("scanResult");case "jobMissing"->remote.put("jobId",null);case "wrongArtifact"->remote.put("artifactSha256","0".repeat(64));case "malformed"->scan.put("remoteReview","bad");} + }); + var original=read(id).work().stream().filter(w->w.versionId().equals("v")).findFirst().orElseThrow();assertEquals(ProjectMutationPriorWorkReader.Kind.UNRESOLVED,original.kind());assertNull(original.binding()); + } + @Test void heldChainPreservesRemovedJobsFromEarlierReplacementGroup(){ + String next=editHeld("next");var result=read(next);assertEquals(List.of(next,base.prepared.id()),result.groups()); + assertEquals(1,result.work().stream().filter(w->w.kind()==ProjectMutationPriorWorkReader.Kind.REMOTE_JOB).count()); + assertTrue(result.work().stream().anyMatch(w->w.mutationId().equals(base.prepared.id()) && w.versionId().equals("v"))); + base.base.base.base.fixture.mongo.getCollection("projects").deleteMany(new Document());assertEquals(result,read(next)); + } + @Test void alteredHeldPointerCannotHideEarlierWork(){ + var mongo=base.base.base.base.fixture.mongo;mongo.getCollection("projects").updateOne(new Document("_id",base.project),new Document("$set",new Document("versions.1.versionMutation.beforeSha256","0".repeat(64)))); + String next=editHeld("changed");assertThrows(IllegalStateException.class,()->read(next)); + } + @Test void chainLimitFailsClosedInsteadOfReturningPartialInventory(){ + String last=base.prepared.id();for(int i=0;i<7;i++)last=editHeld("runtime-"+i);assertEquals(8,read(last).groups().size()); + String ninth=editHeld("runtime-8");assertThrows(IllegalStateException.class,()->read(ninth)); + } + @Test void permissionAndSharedShutdownPreventReading(){ + assertThrows(SecurityException.class,()->reader.read(base.project,base.prepared.id(),()->false));base.budget.close();assertThrows(IllegalStateException.class,()->read(base.prepared.id())); + } + @Test void unsupportedPriorTransitionIsExplicitEvenWhenItsCurrentJobIsValid(){ + String id=restage(v->v.put("reviewReplacement",new Document("operationId",UUID.randomUUID().toString()))); + var work=read(id).work();assertTrue(work.stream().anyMatch(w->w.kind()==ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)); + assertTrue(work.stream().anyMatch(w->"PRIOR_TRANSITION_REQUIRES_ACCOUNTING".equals(w.reason()))); + } + @Test void workLimitDoesNotSilentlyOmitRemovedVersions(){ + var original=base.base.root();var many=new ArrayList(); + for(int i=0;i<257;i++)many.add(new Document("_id","old-"+i).append("reviewStatus","PENDING")); + original.put("versions",many);original.put("status","DRAFT"); + base.base.base.base.fixture.mongo.getCollection("projects").replaceOne(new Document("_id",base.project),original); + var captured=base.base.base.service.capture(base.project,()->true);original.put("versions",List.of()); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),base.project,captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(original)); + var prepared=base.base.base.service.prepare(request,()->true);assertEquals("APPLIED",base.base.executor().apply(prepared,"owner",()->true).state()); + assertThrows(IllegalStateException.class,()->read(prepared.id())); + } + String restage(Consumer edit){ + var old=new RawBsonDocument(base.base.base.base.archive.load(base.prepared.beforeArchiveId()).versionBytes()).decode(new DocumentCodec());edit.accept(old.getList("versions",Document.class).getFirst()); + base.base.base.base.fixture.mongo.getCollection("projects").replaceOne(new Document("_id",base.project),old); + var prepared=base.base.base.service.prepare(base.base.base.request(),()->true);assertEquals("APPLIED",base.base.executor().apply(prepared,"owner",()->true).state());return prepared.id(); + } + String editHeld(String runtime){ + var captured=base.base.base.service.capture(base.project,()->true);var next=new RawBsonDocument(captured.bytes()).decode(new DocumentCodec()); + next.getList("versions",Document.class).stream().filter(v->"new-upload".equals(v.get("_id"))).findFirst().orElseThrow().put("gameVersions",List.of(runtime)); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),base.project,captured.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(next)); + var prepared=base.base.base.service.prepare(request,()->true);assertEquals("APPLIED",base.base.executor().apply(prepared,"owner",()->true).state());return prepared.id(); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationReferenceReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationReferenceReaderTest.java new file mode 100644 index 000000000..4838eb1fd --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationReferenceReaderTest.java @@ -0,0 +1,131 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationReferenceReaderTest { + ProjectMutationExecutorTest base=new ProjectMutationExecutorTest();ProjectMutationReferenceReader reader; + @BeforeEach void setup()throws Exception{base.setup();reader=create();reader.initialize();} + @AfterEach void cleanup(){base.cleanup();} + ProjectMutationReferenceReader create(){return new ProjectMutationReferenceReader(base.base.base.fixture.mongo,base.base.base.archive,base.base.service,base.executor());} + Object project(){return base.base.base.raw().get("_id");} + org.springframework.data.mongodb.core.MongoTemplate mongo(){return base.base.base.fixture.mongo;} + @Test void appliedGroupRemainsReadableAfterVisibleProjectDeletion(){ + var id=project();var prepared=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(prepared,"owner",()->true); + var committed=VersionMutationPreparationTest.bytes(base.root()); + assertEquals(List.of(prepared.id()),reader.page(id,null,64,()->true).operationIds());mongo().getCollection("projects").deleteMany(new Document()); + var history=create().read(id,prepared.id(),()->true);assertEquals(prepared,history.evidence().prepared());assertEquals("APPLIED",history.receipt().state()); + assertArrayEquals(committed,history.applied().versionBytes());assertEquals(0,base.base.base.fixture.posts.get());assertEquals(0,base.base.base.fixture.gets.get()); + } + @Test void paginationIsExclusiveAndBoundToExactProjectBsonIdentity(){ + var id=project();var refs=mongo().getCollection(ProjectMutationExecutor.REFERENCES);var ids=new ArrayList();for(int i=0;i<3;i++)ids.add(UUID.randomUUID().toString());Collections.sort(ids); + for(var op:ids)refs.insertOne(new Document("_id",op).append("projectId",id).append("state","HELD")); + refs.insertOne(new Document("_id",UUID.randomUUID().toString()).append("projectId",id.toString()).append("state","HELD")); + var first=reader.page(id,null,2,()->true);assertEquals(ids.subList(0,2),first.operationIds());assertNotNull(first.nextCursor()); + var second=reader.page(id,first.nextCursor(),2,()->true);assertEquals(List.of(ids.getLast()),second.operationIds());assertNull(second.nextCursor()); + assertThrows(IllegalArgumentException.class,()->reader.page(id.toString(),first.nextCursor(),2,()->true)); + assertThrows(RuntimeException.class,()->reader.read(id,ids.getFirst(),()->true)); + } + @ParameterizedTest @ValueSource(strings={"actor","state","digest","extra","receipt","child","archive"}) + void inventoryDoesNotAuthenticateTamperedHistory(String change){ + var id=project();var prepared=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(prepared,"owner",()->true); + var collection=change.equals("receipt")?ReviewRepairJournal.COLLECTION:ProjectMutationExecutor.REFERENCES; + var field=switch(change){case "actor"->"actor";case "state","receipt"->"state";case "digest"->"beforeSha256";case "child"->"versions.0.afterSha256";case "archive"->"afterArchiveId";default->"extra";}; + mongo().getCollection(collection).updateOne(new Document("_id",prepared.id()),new Document("$set",new Document(field,"changed"))); + assertThrows(RuntimeException.class,()->reader.read(id,prepared.id(),()->true)); + } + @Test void missingIndexAndRevokedPermissionFailClosed(){ + var id=project();assertThrows(SecurityException.class,()->reader.page(id,null,1,()->false)); + mongo().getCollection(ProjectMutationExecutor.REFERENCES).dropIndex(ProjectMutationReferenceReader.INDEX); + assertThrows(com.mongodb.MongoException.class,()->reader.page(id,null,1,()->true)); + assertThrows(SecurityException.class,()->reader.read(id,UUID.randomUUID().toString(),()->false)); + } + @Test void malformedInventoryCannotMasqueradeAsAValidScopedRecord(){ + var id=project();var refs=mongo().getCollection(ProjectMutationExecutor.REFERENCES); + refs.insertOne(new Document("_id",UUID.randomUUID().toString()).append("projectId",List.of(id)).append("state","HELD")); + refs.insertOne(new Document("_id","invalid-id").append("projectId",id).append("state","HELD")); + assertTrue(reader.page(id,null,64,()->true).operationIds().isEmpty()); + assertThrows(IllegalArgumentException.class,()->reader.page(id,null,65,()->true)); + } + @Test void accessRevokedDuringPageReadPreventsReturningInventory(){ + var calls=new java.util.concurrent.atomic.AtomicInteger();var id=project(); + assertThrows(SecurityException.class,()->reader.page(id,null,1,()->calls.getAndIncrement()==0));assertEquals(2,calls.get()); + } + + @ParameterizedTest @ValueSource(strings={"childState","indexType","missingChild","archiveIdentity"}) + void signedRootsRejectAlteredChildSummariesAndArchiveSubstitution(String change){ + var id=project();var prepared=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(prepared,"owner",()->true); + var refs=mongo().getCollection(ProjectMutationExecutor.REFERENCES);var ref=refs.find(new Document("_id",prepared.id())).first(); + switch(change){ + case "childState" -> ref.getList("versions",Document.class).getFirst().put("state","RETAINED"); + case "indexType" -> ref.getList("versions",Document.class).getFirst().put("beforeIndex",0L); + case "missingChild" -> ref.getList("versions",Document.class).removeFirst(); + case "archiveIdentity" -> { + var original=base.base.base.archive.load(ref.getString("afterArchiveId"));var substitute=UUID.randomUUID().toString(); + base.base.base.archive.retain(new ReviewSnapshotArchive.Snapshot(substitute,id,0,original.actorId(),original.action(),original.createdAt(),original.expiresAt(),original.versionBytes())); + ref.put("afterArchiveId",substitute); + } + } + refs.replaceOne(new Document("_id",prepared.id()),ref); + assertThrows(RuntimeException.class,()->reader.read(id,prepared.id(),()->true)); + } + + @Test void heldHeadAllowsMetadataAndPositionChangesWithoutNewReview(){ + var prepared=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(prepared,"owner",()->true); + var current=base.root();var versions=current.getList("versions",Document.class);versions.get(1).put("changelog","Updated description"); + Collections.reverse(versions);reader.requireHeldHeads(project(),VersionMutationPreparationTest.bytes(current),()->true); + } + @ParameterizedTest @ValueSource(strings={"missingPointer","request","attempt","context","artifact","hold","state","unknown","pointer"}) + void heldHeadRejectsAlteredSecurityProjection(String change){ + var prepared=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(prepared,"owner",()->true); + var current=base.root();var version=current.getList("versions",Document.class).get(1);var scan=version.get("scanResult",Document.class); + switch(change){ + case "missingPointer" -> version.remove("versionMutation"); + case "request" -> scan.put("scanRequestId",UUID.randomUUID().toString()); + case "attempt" -> scan.put("scanAttempt",1L); + case "context" -> version.put("gameVersions",List.of("altered")); + case "artifact" -> version.put("fileUrl","altered.jar"); + case "hold" -> version.put("replacementSecurityHold",UUID.randomUUID().toString()); + case "state" -> scan.put("scanState","COMPLETE"); + case "unknown" -> version.put("futureAuthority",true); + case "pointer" -> version.get("versionMutation",Document.class).put("beforeSha256","0".repeat(64)); + } + assertThrows(RuntimeException.class,()->reader.requireHeldHeads(project(),VersionMutationPreparationTest.bytes(current),()->true)); + } + @Test void workflowCanApplySecondContextChangeWhileAuthenticatingPriorGroup(){ + var first=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(first,"owner",()->true); + var budget=new ReviewRepairWorkflow(org.mockito.Mockito.mock(ReviewRepairPreparation.class),org.mockito.Mockito.mock(ReviewIsolationExecutor.class),1); + var workflow=new ProjectMutationWorkflow(budget,base.base.service,base.executor(),reader); + var current=workflow.capture(project(),()->true);var proposed=new RawBsonDocument(current.bytes()).decode(new org.bson.codecs.DocumentCodec()); + proposed.getList("versions",Document.class).get(1).put("gameVersions",List.of("new-runtime")); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),project(),current.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(proposed)); + var second=workflow.prepare(request,()->true);assertEquals("APPLIED",workflow.apply(second,"owner",()->true).state()); + reader.requireHeldHeads(project(),VersionMutationPreparationTest.bytes(base.root()),()->true); + assertEquals(2,base.root().getList("versions",Document.class).get(1).get("scanResult",Document.class).get("scanAttempt")); + assertEquals(0,budget.status().active());budget.close(); + } + + @ParameterizedTest @ValueSource(booleans={false,true}) + void workflowRejectsLostPriorReferenceBeforePreparationOrApplication(boolean afterPreparation){ + var first=base.base.service.prepare(base.base.request(),()->true);base.executor().apply(first,"owner",()->true); + var budget=new ReviewRepairWorkflow(org.mockito.Mockito.mock(ReviewRepairPreparation.class),org.mockito.Mockito.mock(ReviewIsolationExecutor.class),1); + var workflow=new ProjectMutationWorkflow(budget,base.base.service,base.executor(),reader);var current=workflow.capture(project(),()->true); + var proposed=new RawBsonDocument(current.bytes()).decode(new org.bson.codecs.DocumentCodec());proposed.getList("versions",Document.class).get(1).put("gameVersions",List.of("changed")); + var request=new ProjectMutationPreparation.Request(UUID.randomUUID().toString(),project(),current.sha256(),"owner",ProjectMutationPreparation.Mutation.VERSION_LIST,VersionMutationPreparationTest.bytes(proposed)); + var prepared=afterPreparation?workflow.prepare(request,()->true):null; + mongo().getCollection(ProjectMutationExecutor.REFERENCES).deleteOne(new Document("_id",first.id())); + if(afterPreparation)assertThrows(RuntimeException.class,()->workflow.apply(prepared,"owner",()->true)); + else assertThrows(RuntimeException.class,()->workflow.prepare(request,()->true)); + assertArrayEquals(current.bytes(),VersionMutationPreparationTest.bytes(base.root())); + assertNull(mongo().getCollection(ReviewRepairJournal.COLLECTION).find(new Document("_id",request.id())).first()); + assertEquals(0,budget.status().active());budget.close(); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationTraversalTest.java b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationTraversalTest.java new file mode 100644 index 000000000..1629079f3 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ProjectMutationTraversalTest.java @@ -0,0 +1,43 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.bson.RawBsonDocument; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ProjectMutationTraversalTest { + ProjectMutationObservationProgressTest fixture=new ProjectMutationObservationProgressTest(); + @AfterEach void cleanup(){fixture.cleanup();} + @Test void maximumJobInventoryTraversesRealBackgroundGenerationsWithoutRepeatedReads()throws Exception{ + fixture.setup(255);var base=fixture.base;var f=base.f();var candidate=base.candidate(); + var archive=base.base.base.base.base.base.archive; + long started=System.nanoTime(); + try(var budget=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),2); + var pipeline=new ProjectMutationParallelHistoryTest().pipeline(f.mongo,budget,archive,f.client); + var scheduler=new ProjectMutationAdmissionScheduler(new ProjectMutationDiscovery(f.mongo),pipeline.automatic(),new ProjectMutationAdmissionScheduler.Settings(1,4,100,10000))){ + scheduler.start();long deadline=System.nanoTime()+TimeUnit.MINUTES.toNanos(10);String state=null; + while(System.nanoTime(){calls.incrementAndGet();if(exchange.getRequestMethod().equals("POST"))posts.incrementAndGet(); + try {assertEquals(expectedApiKey,exchange.getRequestHeaders().getFirst("X-Warden-Api-Key")); + if(exchange.getRequestURI().getPath().endsWith("/identity")){reply(exchange,200,origin);return;} + assertEquals(origin.deploymentId(),exchange.getRequestHeaders().getFirst("X-Warden-Deployment-Id"));assertEquals(origin.callerScope(),exchange.getRequestHeaders().getFirst("X-Warden-Caller-Scope"));handler.handle(exchange);} + catch(Exception failure){throw new RuntimeException(failure);}finally{exchange.close();}}); + } + void reply(HttpExchange exchange,int code,Object value)throws Exception { + byte[] body=mapper.writeValueAsBytes(value);exchange.getResponseHeaders().set("Content-Type","application/json"); + exchange.sendResponseHeaders(code,body.length);exchange.getResponseBody().write(body); + } + Map status(String state) { + var result=new LinkedHashMap();result.put("jobId",job);result.put("requestId",request);result.put("binding",wireBinding()); + result.put("state",state);result.put("artifactRetained",!state.equals("AWAITING_UPLOAD"));result.put("createdAt",1000L);result.put("expiresAt",2000L);result.put("workState",null);return result; + } + Map wireBinding(){return Map.of("artifactSha256",binding.artifactSha256(),"contextSha256",binding.contextSha256(),"policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256());} + @BeforeEach void setup()throws Exception { + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());server.start(); + client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"fixture-key",true,9,600),Duration.ofSeconds(2)); + binding=new RemoteReviewBinding("p","v",request,1,"original.zip",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)), + "b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null,false,origin); + } + @AfterEach void cleanup(){client.close();server.stop(0);} + @Test void lostSubmissionResponseIsRecoveredBySameRequestWithoutAnotherPost() { + var accepted=new AtomicBoolean();route(e->{ + if(e.getRequestMethod().equals("POST")){assertTrue(new String(e.getRequestBody().readAllBytes()).contains(request));accepted.set(true);return;} + assertTrue(e.getRequestURI().getPath().endsWith(request));assertTrue(e.getRequestURI().getQuery().contains(binding.contextSha256())); + reply(e,accepted.get()?200:404,accepted.get()?status("QUEUED"):Map.of()); + }); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.submitOrFind(binding,bytes)); + assertEquals(job,client.submitOrFind(binding,bytes).jobId());assertEquals(1,posts.get());assertEquals(3,calls.get()); + } + @Test void awaitingUploadResubmitsOnlyOriginalRequestAndRequiresSameJob() { + route(e->{if(e.getRequestMethod().equals("POST")){e.getRequestBody().readAllBytes();var changed=status("QUEUED");changed.put("jobId",UUID.randomUUID().toString());reply(e,202,changed);}else reply(e,200,status("AWAITING_UPLOAD"));}); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.submitOrFind(binding,bytes));assertEquals(1,posts.get()); + } + @Test void submissionRejectsWrongOriginalBeforeAnyHttp() { + route(e->reply(e,200,status("QUEUED")));assertThrows(IllegalArgumentException.class,()->client.submitOrFind(binding,new byte[]{1}));assertEquals(0,calls.get()); + } + @Test void mismatchedIdentityAndUnknownStateNeverBecomeUsableStatus() { + var response=new AtomicReference<>(status("QUEUED"));route(e->reply(e,200,response.get())); + response.get().put("requestId",UUID.randomUUID().toString());assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding)); + response.set(status("NEW_UNKNOWN_STATE"));assertThrows(RemoteReviewClient.Unavailable.class,()->client.status(binding.withJobId(job))); + response.set(status("QUEUED"));response.get().put("binding",Map.of());assertThrows(RemoteReviewClient.Unavailable.class,()->client.status(binding.withJobId(job))); + } + @Test void redirectsAndTemporaryFailuresAreNotRetriedOrTurnedIntoFindings() { + route(e->{e.getResponseHeaders().set("Location","/leaked");reply(e,307,Map.of());}); + assertEquals(307,assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding)).status());assertEquals(1,calls.get()); + } + @Test void onlyNotFoundPermitsSubmission() { + route(e->reply(e,503,Map.of()));assertEquals(503,assertThrows(RemoteReviewClient.Unavailable.class,()->client.submitOrFind(binding,bytes)).status());assertEquals(0,posts.get());assertEquals(1,calls.get()); + } + @Test void boundResultRetainsOriginalContextAndRejectsWrongJob() { + var response=new LinkedHashMap();response.put("jobId",job);response.put("requestId",request);response.put("binding",wireBinding());response.put("completedAt",1500L); + var scan=ScanEvidenceFixtures.complete(true);var old=scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(binding.policyVersion(),binding.artifactSha256(),old.contentSha256(),true,true,old.reviewState(),old.entryHashes()));response.put("scan",scan); + route(e->reply(e,200,response));var result=client.result(binding.withJobId(job)); + assertTrue(result.isArtifactVerified());assertEquals(binding.contextSha256(),result.getReviewedContextSha256());assertEquals(request,result.getScanRequestId()); + response.put("jobId",UUID.randomUUID().toString());assertThrows(RemoteReviewClient.Unavailable.class,()->client.result(binding.withJobId(job))); + } + @Test void duplicateJsonKeysAndOversizedStatusAreRejected() { + var raw=new AtomicReference<>("{\"policyVersion\":\"a\",\"policyVersion\":\"b\"}");route(e->{byte[] body=raw.get().getBytes();e.sendResponseHeaders(200,body.length);e.getResponseBody().write(body);}); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration());raw.set(" ".repeat(65537));assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration()); + } + @Test void closeCancelsActiveRequestsAndRejectsFurtherCalls()throws Exception { + var entered=new CountDownLatch(1);var release=new CountDownLatch(1);route(e->{entered.countDown();release.await(5,TimeUnit.SECONDS);}); + try(var executor=Executors.newSingleThreadExecutor()) { + var pending=executor.submit(()->assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding))); + try {assertTrue(entered.await(2,TimeUnit.SECONDS));client.close();pending.get(1,TimeUnit.SECONDS);assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding));} + finally{release.countDown();} + } + assertEquals(1,calls.get()); + } + @Test void thirdConcurrentRequestIsRejectedWithoutHttpAndCloseDrainsBoth()throws Exception { + var entered=new CountDownLatch(2);var release=new CountDownLatch(1);route(e->{entered.countDown();release.await(5,TimeUnit.SECONDS);}); + try(var executor=Executors.newFixedThreadPool(2)) { + var first=executor.submit(()->assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding))); + var second=executor.submit(()->assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding))); + try {assertTrue(entered.await(2,TimeUnit.SECONDS));assertEquals(503,assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding)).status()); + assertEquals(2,calls.get());client.close();first.get(1,TimeUnit.SECONDS);second.get(1,TimeUnit.SECONDS);} + finally{release.countDown();} + } + } + @Test void deadlineCancelsSlowResponseWithoutRetry() { + client.close();client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"fixture-key",true,9,600),Duration.ofMillis(150)); + var release=new CountDownLatch(1);route(e->release.await(3,TimeUnit.SECONDS)); + long start=System.nanoTime(); + try {assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding));assertTrue(System.nanoTime()-start{if(e.getRequestURI().getPath().endsWith("configuration"))reply(e,200,Map.of("policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + else {assertEquals("DELETE",e.getRequestMethod());assertTrue(e.getRequestURI().getQuery().contains(request));reply(e,200,status("CANCELLED"));}}); + var configuration=client.configuration();assertEquals(origin,configuration.origin());assertEquals(binding.reviewConfigSha256(),configuration.reviewConfigSha256());assertEquals("CANCELLED",client.cancel(binding.withJobId(job)).state()); + } + @Test void legacyOriginsNeverTriggerDiscoveryUploadStatusResultOrCancellation() { + var legacy=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(),binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),binding.reviewConfigSha256(),null); + route(e->reply(e,200,status("QUEUED")));var downloads=new AtomicInteger(); + assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.find(legacy));assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.status(legacy.withJobId(job))); + assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.cancel(legacy.withJobId(job)));assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.result(legacy.withJobId(job))); + assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.submitOrFind(legacy,()->{downloads.incrementAndGet();return bytes;},()->true));assertEquals(0,calls.get());assertEquals(0,downloads.get()); + } + @Test void storedOriginSurvivesClientRecreationAndCredentialRotationWithoutRediscovery() { + route(e->reply(e,200,status("QUEUED")));var attached=binding.withJobId(job);assertEquals(origin,attached.origin());client.status(attached);client.close();expectedApiKey="rotated-fixture-key"; + client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),expectedApiKey,true,1,600),Duration.ofSeconds(2)); + client.status(attached);assertEquals(2,calls.get()); + } + @Test void malformedIdentityResponsesCannotSupplyConfiguration() { + var value=new AtomicReference(Map.of("deploymentId","invalid","callerScope",origin.callerScope())); + server.createContext("/api/v1/review-jobs/identity",e->{try{reply(e,200,value.get());}catch(Exception invalid){throw new RuntimeException(invalid);}finally{e.close();}}); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration());value.set(Map.of("deploymentId",origin.deploymentId(),"callerScope",origin.callerScope(),"extra",true)); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration());assertEquals(0,calls.get()); + } + @Test void changedDeploymentBetweenIdentityAndConfigurationIsNotRetriedOrRebound() { + route(e->reply(e,409,Map.of()));assertEquals(409,assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration()).status());assertEquals(2,calls.get());assertEquals(0,posts.get()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java new file mode 100644 index 000000000..86644cb39 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java @@ -0,0 +1,71 @@ +package net.modtale.service.security.scan; + +import com.mongodb.client.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class RemoteReviewDiscoveryTest { + MongoClient client;MongoDatabase db;MongoCollection projects;RemoteReviewDiscovery discovery; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_discovery_"+UUID.randomUUID().toString().replace("-",""));projects=db.getCollection("projects"); + discovery=new RemoteReviewDiscovery(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document version(String id) {return new Document("_id",id).append("reviewStatus","PENDING").append("scanResult",new Document("status","SCANNING").append("scanState","QUEUED").append("scanAttempt",1).append("scanRequestId",UUID.randomUUID().toString()));} + void insert(Object id,List versions){projects.insertOne(new Document("_id",id).append("versions",versions));} + List walk(int size) { + var found=new ArrayList();RemoteReviewDiscovery.Cursor cursor=null; + for(int i=0;i<100;i++){var page=discovery.page(cursor,size);assertTrue(page.examined()<=size);found.addAll(page.candidates());cursor=page.next();if(cursor==null)return found;} + fail("Discovery cursor did not terminate");return found; + } + @Test void everyVersionIsVisitedAcrossBoundedPagesAndMixedIdTypes() { + var versions=new ArrayList();for(int i=0;i<71;i++)versions.add(version("v"+i));insert("string-project",versions);var objectId=new ObjectId();insert(objectId,List.of(version("last"))); + var first=discovery.page(null,16);assertEquals(16,first.examined());assertEquals(16,first.candidates().size());assertFalse(first.next().afterProject());assertEquals(16,first.next().offset()); + var all=walk(16);assertEquals(72,all.size());assertEquals(72,all.stream().map(c->c.projectId()+":"+c.versionId()).distinct().count());assertEquals(objectId.toHexString(),all.getLast().projectId()); + } + @Test void activeLeasesAndFutureRetryDatesAreSkippedUsingDatabaseTime() { + var now=db.runCommand(new Document("hello",1)).getDate("localTime").getTime();var versions=new ArrayList(); + for(int i=0;i<4;i++){var v=version("v"+i);var scan=v.get("scanResult",Document.class);scan.put("scanState","REMOTE_REVIEW"); + scan.put("remotePoll",new Document("token",i==2?null:UUID.randomUUID().toString()).append("leaseUntil",new Date(i==0?now+60000:0)).append("nextPollAt",new Date(i==1?now+60000:0)));versions.add(v);} + insert("p",versions);var found=walk(2);assertEquals(List.of("v2","v3"),found.stream().map(RemoteReviewDiscovery.Candidate::versionId).toList()); + } + @Test void emptyCandidatePageStillAdvancesPastBusyVersions() { + var busy=version("busy");busy.get("scanResult",Document.class).put("scanState","REMOTE_REVIEW");busy.get("scanResult",Document.class).put("remotePoll",new Document("token",null).append("leaseUntil",new Date(Long.MAX_VALUE)).append("nextPollAt",new Date(0))); + insert("p",List.of(busy,version("ready")));var first=discovery.page(null,1);assertTrue(first.candidates().isEmpty());assertEquals(1,first.next().offset()); + assertEquals("ready",discovery.page(first.next(),1).candidates().getFirst().versionId()); + } + @Test void deletedCursorProjectDoesNotApplyItsOffsetToTheNextProject() { + insert("a",List.of(version("one"),version("two")));insert("b",List.of(version("next")));var first=discovery.page(null,1); + projects.deleteOne(new Document("_id","a"));var next=discovery.page(first.next(),1);assertEquals("next",next.candidates().getFirst().versionId());assertTrue(next.next().afterProject()); + } + @Test void malformedMetadataAndPollsCannotBecomeCandidates() { + var badRequest=version("bad-request");badRequest.get("scanResult",Document.class).put("scanRequestId","x".repeat(100000)); + var badAttempt=version("bad-attempt");badAttempt.get("scanResult",Document.class).put("scanAttempt",Long.MAX_VALUE); + var malformed=version("bad-poll");malformed.get("scanResult",Document.class).put("remotePoll",List.of("invalid")); + var nullPoll=version("null-poll");nullPoll.get("scanResult",Document.class).put("remotePoll",null); + var objectPoll=version("bad-dates");objectPoll.get("scanResult",Document.class).put("scanState","REMOTE_REVIEW");objectPoll.get("scanResult",Document.class).put("remotePoll",new Document("token",null).append("leaseUntil","bad").append("nextPollAt",new Date(0))); + insert("p",List.of(badRequest,badAttempt,malformed,nullPoll,objectPoll,version("ready")));assertEquals(List.of("ready"),walk(2).stream().map(RemoteReviewDiscovery.Candidate::versionId).toList()); + } + @Test void nonScanningProjectsAndUnsupportedRootIdsCannotStarveValidProjects() { + insert(1,List.of(version("numeric")));insert("x".repeat(129),List.of(version("long-root"))); + var finished=version("done");finished.get("scanResult",Document.class).put("status","CLEAN");insert("a",List.of(finished));insert("b",List.of(version("ready"))); + assertEquals(List.of("ready"),walk(1).stream().map(RemoteReviewDiscovery.Candidate::versionId).toList()); + } + @Test void shrinkingVersionArrayAdvancesRatherThanRepeatingItsLastPage() { + insert("a",List.of(version("one"),version("two")));insert("b",List.of(version("next")));var first=discovery.page(null,1); + projects.updateOne(new Document("_id","a"),new Document("$set",new Document("versions",List.of(version("one"))))); + var exhausted=discovery.page(first.next(),1);assertEquals(0,exhausted.examined());assertTrue(exhausted.next().afterProject());assertEquals("next",discovery.page(exhausted.next(),1).candidates().getFirst().versionId()); + } + @Test void pageAndCursorLimitsAreValidated() { + assertThrows(IllegalArgumentException.class,()->discovery.page(null,0));assertThrows(IllegalArgumentException.class,()->discovery.page(null,65)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewDiscovery.Cursor("p",-1,false));assertThrows(IllegalArgumentException.class,()->new RemoteReviewDiscovery.Cursor("p",1,true)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewDiscovery.Cursor(1,0,true));assertNull(discovery.page(null,1).next()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java new file mode 100644 index 000000000..e635d6329 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java @@ -0,0 +1,79 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class RemoteReviewIsolationIntegrationTest { + RemoteReviewStepIntegrationTest fixture=new RemoteReviewStepIntegrationTest(); + ReviewSnapshotArchive archive;RawReviewSnapshotReader reader;ReviewIsolationExecutor isolation; + ReviewRepairPreparation.Prepared prepared; + @BeforeEach void setup()throws Exception { + fixture.setup(System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031"),true); + reader=new RawReviewSnapshotReader(fixture.mongo);archive=new ReviewSnapshotArchive(fixture.mongo,"test",Map.of("test",new byte[32])); + isolation=new ReviewIsolationExecutor(fixture.mongo,archive,reader,new ReviewRepairJournal(fixture.mongo,archive)); + } + @AfterEach void cleanup(){fixture.cleanup();} + Document raw(){return fixture.mongo.getCollection("projects").find().first();} + void isolateExpiredBrokenPoll() { + fixture.change("scanResult.remotePoll.leaseUntil",new Date(0)); + fixture.change("scanResult.remotePoll.extra","invalid poll shape"); + Object rootId=raw().get("_id");var capture=reader.capture(rootId,0,"v"); + prepared=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000).prepare( + new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),rootId,0,"v",capture.sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW),()->true); + assertEquals("APPLIED",isolation.execute(prepared,"actor",()->true).state()); + assertArrayEquals(capture.versionBytes(),archive.load(prepared.id()).versionBytes()); + } + void assertIsolated() { + var version=fixture.mongo.findById(fixture.project,Project.class).getVersions().getFirst(); + assertEquals(ProjectVersion.ReviewStatus.PENDING,version.getReviewStatus());assertEquals("REMOTE_ISOLATED",version.getScanResult().getScanState()); + assertEquals(ScanStatus.FAILED,version.getScanResult().getStatus());assertFalse(version.getScanResult().isArtifactVerified()); + assertNull(version.getScheduledPublishDate());assertEquals(0,version.getSecurityApprovedAt()); + assertEquals(fixture.job,version.getScanResult().getRemoteReview().jobId()); + assertEquals(prepared.id(),version.getReviewIsolation().operationId()); + } + @Test void isolatedVersionRejectsStaleCompletionAndEveryPollMutation() { + var claim=fixture.attached(30000);fixture.change("scanResult.verdict","BLOCK");isolateExpiredBrokenPoll();var before=raw(); + assertFalse(fixture.completion.handleRemoteCompletedScan(claim,fixture.cleanResult())); + assertFalse(fixture.polls.isCurrent(claim));assertFalse(fixture.polls.release(claim,100));assertNull(fixture.polls.claim(claim.binding(),1000)); + assertNull(fixture.polls.attachJob(claim,fixture.job)); + var status=new RemoteReviewClient.Status(fixture.job,"QUEUED",true,1000,2000,null); + assertFalse(fixture.polls.recordStatusAndRelease(claim,status,100)); + assertFalse(fixture.polls.finishUnavailable(claim,new RemoteReviewClient.Status(fixture.job,"CANCELLED",true,1000,2000,null))); + assertEquals("NO_WORK",fixture.step.advance(claim.binding()).state());assertTrue(new RemoteReviewDiscovery(fixture.mongo).page(null,64).candidates().isEmpty()); + assertEquals(before,raw());assertIsolated();assertEquals("BLOCK",fixture.saved().getVerdict());assertEquals(0,fixture.posts.get());assertEquals(0,fixture.gets.get()); + verifyNoInteractions(fixture.storage); + } + @Test void cleanHttpResultArrivingAfterIsolationCannotRestoreClearance() { + var reached=new AtomicBoolean(); + fixture.route(e->{if(e.getRequestURI().getPath().endsWith("/result")){isolateExpiredBrokenPoll();reached.set(true);}fixture.reply(e,200,"COMPLETED");}); + assertEquals("SUPERSEDED",fixture.step.advance(fixture.binding).state());assertTrue(reached.get());assertIsolated(); + assertEquals(0,fixture.posts.get());assertEquals(2,fixture.gets.get());verifyNoInteractions(fixture.storage); + } + @Test void isolationAfterCompletionSnapshotRejectsFinalPublicationWrite() { + var claim=fixture.attached(30000);var reached=new AtomicBoolean(); + when(fixture.policy.currentPolicyVersion()).thenAnswer(i->{isolateExpiredBrokenPoll();reached.set(true);return fixture.binding.policyVersion();}); + assertFalse(fixture.completion.handleRemoteCompletedScan(claim,fixture.cleanResult()));assertTrue(reached.get());assertIsolated(); + assertEquals("APPLIED",isolation.recover(prepared.id(),"actor").receipt().outcome().state()); + } + @Test void manualRescanCannotEraseAnUnaccountedRemoteJobOrItsIsolatedReference() { + fixture.attached(30000);fixture.change("scanResult.verdict","BLOCK"); + for(boolean isolated:List.of(false,true)) { + if(isolated)isolateExpiredBrokenPoll(); + var before=raw();var version=fixture.mongo.findById(fixture.project,Project.class).getVersions().getFirst(); + var writes=new VersionReviewPersistence(fixture.mongo);var snapshot=writes.captureForRescan(fixture.project,"v",VersionReviewSnapshot.rescanToken(version)); + var queued=new ScanResult();queued.setStatus(ScanStatus.SCANNING);queued.setScanState("QUEUED");queued.setScanAttempt(2); + var error=assertThrows(org.springframework.web.server.ResponseStatusException.class,()->writes.queueRescan(snapshot,queued)); + assertEquals(409,error.getStatusCode().value());assertEquals(before,raw());assertEquals("BLOCK",fixture.saved().getVerdict()); + } + assertEquals(0,fixture.posts.get());assertEquals(0,fixture.gets.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java new file mode 100644 index 000000000..ef4ca6a05 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java @@ -0,0 +1,70 @@ +package net.modtale.service.security.scan; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RemoteReviewSchedulerTest { + RemoteReviewDiscovery discovery=mock(RemoteReviewDiscovery.class); + RemoteReviewBootstrap bootstrap=mock(RemoteReviewBootstrap.class); + RemoteReviewScheduler scheduler(int workers,long drain){return new RemoteReviewScheduler(discovery,bootstrap,new RemoteReviewScheduler.Settings(workers,4,100,drain));} + RemoteReviewDiscovery.Candidate candidate(String id){return new RemoteReviewDiscovery.Candidate("p",id,1,UUID.randomUUID().toString());} + void until(BooleanSupplier ready)throws Exception {long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(3);while(!ready.getAsBoolean()&&System.nanoTime()newKeySet(); + when(discovery.page(any(),anyInt())).thenReturn(new RemoteReviewDiscovery.Page(List.of(a,b,c),new RemoteReviewDiscovery.Cursor("p",0,true),3),new RemoteReviewDiscovery.Page(List.of(),null,0)); + when(bootstrap.advance(anyString(),anyString(),anyInt(),anyString(),any())).thenAnswer(i->{invoked.add(i.getArgument(1));entered.countDown();release.await(3,TimeUnit.SECONDS);return new RemoteReviewStep.Outcome("RECORDED",null);}); + try(var scheduler=scheduler(2,1000)) { + scheduler.start();scheduler.start();try{assertTrue(entered.await(2,TimeUnit.SECONDS));assertEquals(2,scheduler.status().active());assertEquals(1,scheduler.status().buffered());verify(discovery,times(1)).page(any(),anyInt());} + finally{release.countDown();} + until(()->invoked.size()==3);assertEquals(Set.of("a","b","c"),invoked); + } + } + @Test void duplicateInFlightCandidateIsNotDispatchedTwice()throws Exception { + var candidate=candidate("v");var entered=new CountDownLatch(1);var release=new CountDownLatch(1); + when(discovery.page(any(),anyInt())).thenReturn(new RemoteReviewDiscovery.Page(List.of(candidate,candidate),null,2),new RemoteReviewDiscovery.Page(List.of(),null,0)); + when(bootstrap.advance(anyString(),anyString(),anyInt(),anyString(),any())).thenAnswer(i->{entered.countDown();release.await(3,TimeUnit.SECONDS);return new RemoteReviewStep.Outcome("RECORDED",null);}); + try(var scheduler=scheduler(2,1000)){scheduler.start();try{assertTrue(entered.await(2,TimeUnit.SECONDS));Thread.sleep(250);verify(bootstrap,times(1)).advance(anyString(),anyString(),anyInt(),anyString(),any());}finally{release.countDown();}} + } + @Test void discoveryFailureBacksOffInsteadOfSpinning()throws Exception { + when(discovery.page(any(),anyInt())).thenThrow(new IllegalStateException("fixture unavailable")); + try(var scheduler=scheduler(1,1000)){scheduler.start();until(()->scheduler.status().failures()==1);Thread.sleep(250);verify(discovery,times(1)).page(any(),anyInt());assertEquals("POLL_ERROR",scheduler.status().lastOutcome());} + } + @Test void timeoutNeverRunsStopCallbacksBeforeUncooperativeWorkEnds()throws Exception { + var candidate=candidate("v");var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(2);var guard=new AtomicReference(); + when(discovery.page(any(),anyInt())).thenReturn(new RemoteReviewDiscovery.Page(List.of(candidate),null,1),new RemoteReviewDiscovery.Page(List.of(),null,0)); + when(bootstrap.advance(anyString(),anyString(),anyInt(),anyString(),any())).thenAnswer(i->{guard.set(i.getArgument(4));entered.countDown();long limit=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(release.getCount()>0&&System.nanoTime()"DRAIN_TIMEOUT".equals(scheduler.status().state())); + assertTrue(scheduler.isRunning());assertEquals(2,done.getCount());assertFalse(guard.get().getAsBoolean()); + assertThrows(IllegalStateException.class,scheduler::close); + }finally{release.countDown();} + assertTrue(done.await(2,TimeUnit.SECONDS));scheduler.close();assertEquals("STOPPED",scheduler.status().state()); + } + @Test void invalidLimitsAreRejected() { + assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(3,4,100,100));assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(1,65,100,100)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(1,4,99,100));assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(1,4,100,30001)); + } + @Test void springSchedulerIsAbsentByDefault() { + new ApplicationContextRunner().withUserConfiguration(RemoteReviewSchedulerConfig.class).run(context->assertFalse(context.containsBean("remoteReviewScheduler"))); + } + @Test void springSchedulerRequiresRemoteModeAndStopsWithContext() { + empty();var runner=new ApplicationContextRunner().withUserConfiguration(RemoteReviewSchedulerConfig.class).withBean(RemoteReviewDiscovery.class,()->discovery).withBean(RemoteReviewBootstrap.class,()->bootstrap) + .withPropertyValues("app.warden.jobs.scheduler.enabled=true"); + runner.run(context->assertNotNull(context.getStartupFailure())); + var reference=new AtomicReference();runner.withPropertyValues("app.warden.jobs.enabled=true").run(context->{assertNull(context.getStartupFailure());reference.set(context.getBean(RemoteReviewScheduler.class));assertTrue(reference.get().isRunning());}); + assertEquals("STOPPED",reference.get().status().state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java new file mode 100644 index 000000000..d0fc6d1c8 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java @@ -0,0 +1,455 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.client.*; +import com.sun.net.httpserver.*; +import net.modtale.config.properties.AppWardenProperties; +import net.modtale.model.project.*; +import net.modtale.service.storage.StorageService; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; +import java.net.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class RemoteReviewStepIntegrationTest { + MongoClient db;MongoTemplate mongo;String database;HttpServer server;RemoteReviewClient client; + RemoteReviewPollStore polls;StorageService storage;RemoteReviewStep step;RemoteReviewBinding binding; + ScanCompletionService completion;net.modtale.service.security.issue.SecurityIssueAnalysisService analysis; + WardenClientService policy; + byte[] bytes="inert original".getBytes();String project="abcdefabcdefabcdefabcdef",job=UUID.randomUUID().toString(); + RemoteReviewOrigin origin=new RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64)); + AtomicInteger identityGets=new AtomicInteger(),posts=new AtomicInteger(),gets=new AtomicInteger();ObjectMapper mapper=new ObjectMapper(); + interface Handler {void handle(HttpExchange e)throws Exception;} + void route(Handler handler){server.createContext("/api/v1/review-jobs",e->{try{if(e.getRequestURI().getPath().endsWith("/identity")){identityGets.incrementAndGet();byte[] body=mapper.writeValueAsBytes(origin);e.sendResponseHeaders(200,body.length);e.getResponseBody().write(body);return;} + assertEquals(origin.deploymentId(),e.getRequestHeaders().getFirst("X-Warden-Deployment-Id"));assertEquals(origin.callerScope(),e.getRequestHeaders().getFirst("X-Warden-Caller-Scope"));if(e.getRequestMethod().equals("POST")){posts.incrementAndGet();e.getRequestBody().readAllBytes();}else gets.incrementAndGet();handler.handle(e);}catch(Exception failure){throw new RuntimeException(failure);}finally{e.close();}});} + void reply(HttpExchange e,int code,String state)throws Exception { + reply(e,code,state,null); + } + void reply(HttpExchange e,int code,String state,String workState)throws Exception { + var body=new LinkedHashMap();body.put("jobId",job);body.put("requestId",binding.requestId()); + body.put("binding",Map.of("artifactSha256",binding.artifactSha256(),"contextSha256",binding.contextSha256(),"policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + body.put("state",state);body.put("artifactRetained",!Set.of("UPLOADING","AWAITING_UPLOAD").contains(state));body.put("createdAt",1000L);body.put("expiresAt",2000L);body.put("workState",workState); + if(e.getRequestURI().getPath().endsWith("/result")) { + body.remove("state");body.remove("artifactRetained");body.remove("createdAt");body.remove("expiresAt");body.remove("workState"); + body.put("completedAt",1500L);body.put("scan",cleanResult()); + } + byte[] data=mapper.writeValueAsBytes(body);e.sendResponseHeaders(code,data.length);e.getResponseBody().write(data); + } + @BeforeEach void setup()throws Exception { + setup(System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"),false); + } + void setup(String port,boolean replica) throws Exception { + if(!(replica?Set.of("27031","27032"):Set.of("27029","27030")).contains(port))throw new IllegalArgumentException(); + db=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000");database="warden_remote_step_"+UUID.randomUUID().toString().replace("-",""); + var factory=new org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory(db,database); + var conversions=new net.modtale.config.db.MongoConfig().mongoCustomConversions(new net.modtale.config.db.MongoArtifactManifestStore(factory)); + var context=new org.springframework.data.mongodb.core.mapping.MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new org.springframework.data.mongodb.core.convert.MappingMongoConverter(new org.springframework.data.mongodb.core.convert.DefaultDbRefResolver(factory),context); + converter.setCustomConversions(conversions);converter.afterPropertiesSet();mongo=new MongoTemplate(factory,converter); + var version=new ProjectVersion();version.setId("v");version.setFileUrl("original.zip");version.setHash(HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes))); + var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);scan.setScanState("SCANNING");scan.setScanAttempt(1);scan.setScanRequestId(UUID.randomUUID().toString());version.setScanResult(scan); + var root=new Project();root.setId(project);root.setVersions(List.of(version));mongo.insert(root); + binding=new RemoteReviewBinding(project,"v",scan.getScanRequestId(),1,version.getFileUrl(),version.getHash(),ArtifactReviewContext.automaticallyReviewableFingerprint(version),"warden-3.0.0:"+"b".repeat(64),"c".repeat(64),null,false,origin); + assertTrue(new RemoteReviewPersistence(mongo).bind(version,binding));polls=new RemoteReviewPollStore(mongo);storage=mock(StorageService.class);when(storage.downloadBounded(binding.filePath(),100*1024*1024)).thenReturn(bytes); + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());server.start(); + client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"fixture-key",true,1,600),Duration.ofSeconds(2)); + var repo=mock(net.modtale.repository.project.ProjectRepository.class);when(repo.findById(project)).thenAnswer(i->Optional.ofNullable(mongo.findById(project,Project.class))); + var projects=mock(net.modtale.service.project.query.ProjectService.class);analysis=mock(net.modtale.service.security.issue.SecurityIssueAnalysisService.class); + doAnswer(i->{new net.modtale.service.security.issue.SecurityIssueApprovalService(new net.modtale.service.security.issue.SecurityIssueClassificationService( + new net.modtale.config.properties.AppSecurityProperties("fixture",60,120,2,4,15,20,25,2))).markIssuesAcceptedForApprovedVersion(i.getArgument(0));return null;}) + .when(analysis).markIssuesAcceptedForApprovedVersion(any()); + when(analysis.annotateAgainstBaselines(any(),any())).thenReturn(new net.modtale.service.security.issue.SecurityIssueAnalysisService.ClassificationStats(0,0,0,false)); + policy=mock(WardenClientService.class);when(policy.currentPolicyVersion()).thenReturn(binding.policyVersion()); + completion=new ScanCompletionService(repo,projects,mock(net.modtale.service.communication.ProjectNotificationService.class),mock(net.modtale.service.communication.WebhookService.class),analysis, + new ScanRoutingService(new net.modtale.config.properties.AppSecurityProperties("fixture",60,120,2,4,15,20,25,2)), + new ScanPersistenceService(mongo,repo,projects),new net.modtale.service.project.access.ProjectVersionAccessService(null),policy); + completion=spy(completion); + doAnswer(invocation->{try{return invocation.callRealMethod();}catch(RuntimeException failure){throw new AssertionError("Completion failed",failure);}}) + .when(completion).handleRemoteCompletedScan(any(),any()); + step=new RemoteReviewStep(polls,client,storage,completion); + } + @AfterEach void cleanup(){if(client!=null)client.close();if(server!=null)server.stop(0);if(db!=null){db.getDatabase(database).drop();db.close();}} + ScanResult cleanResult() { + var result=ScanEvidenceFixtures.complete(true);var old=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(binding.policyVersion(),binding.artifactSha256(),old.contentSha256(),true,true,old.reviewState(),old.entryHashes())); + result.setReviewedContextSha256(binding.contextSha256());result.setScanRequestId(binding.requestId());return result; + } + ScanResult saved(){return mongo.findById(project,Project.class).getVersions().getFirst().getScanResult();} + void change(String field,Object value){mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("versions.0."+field,value),Project.class);} + void ready(){change("scanResult.remotePoll.nextPollAt",new Date(0));} + @Test void submitThenPersistAndPollExistingJobWithoutRedownload() { + var found=new AtomicBoolean();route(e->{if(e.getRequestMethod().equals("POST")){found.set(true);reply(e,202,"QUEUED");}else reply(e,found.get()?200:404,found.get()?"COMPLETED":"AWAITING_UPLOAD");}); + assertEquals("RECORDED",step.advance(binding).state());assertEquals(job,saved().getRemoteReview().jobId());assertEquals("QUEUED",saved().getRemoteStatus().state());assertNull(saved().getRemotePoll().token()); + assertEquals("NO_WORK",step.advance(saved().getRemoteReview()).state());ready(); + var outcome=new RemoteReviewStep(new RemoteReviewPollStore(mongo),client,storage,completion).advance(saved().getRemoteReview());assertEquals("COMPLETED",outcome.remoteState(),outcome.toString()); + assertEquals("APPLIED",outcome.state());assertEquals("COMPLETED",saved().getScanState());assertTrue(saved().isArtifactVerified()); + assertEquals(ProjectVersion.ReviewStatus.SCHEDULED,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus());assertEquals(binding.contextSha256(),saved().getReviewedContextSha256());assertEquals(1,posts.get());verify(storage,times(1)).downloadBounded(anyString(),anyInt()); + } + RemoteReviewPollStore.Claim attached(long lease) { + var claim=polls.claim(binding,lease);assertNotNull(claim);var attached=polls.attachJob(claim,job);assertNotNull(attached);return attached; + } + @Test void contextChangeDuringResultReadCannotScheduleVersion() { + route(e->{if(e.getRequestURI().getPath().endsWith("/result"))change("manifestVersion","changed");reply(e,200,"COMPLETED");}); + assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals("REMOTE_REVIEW",saved().getScanState()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void contextChangeAfterCompletionSnapshotStillFailsPublicationWrite() { + var claim=attached(30000);when(policy.currentPolicyVersion()).thenAnswer(i->{change("manifestVersion","changed at commit");return binding.policyVersion();}); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void databaseLeaseExpiryDuringPolicyCheckRejectsResultWithoutChangingStoredPoll()throws Exception { + var claim=attached(1000);when(policy.currentPolicyVersion()).thenAnswer(i->{Thread.sleep(1100);return binding.policyVersion();}); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals(claim.token(),saved().getRemotePoll().token());assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void wrongOriginalResultContextAndRequestAreRejected() { + var claim=attached(30000);var result=cleanResult();result.setReviewedContextSha256("d".repeat(64));assertFalse(completion.handleRemoteCompletedScan(claim,result)); + result=cleanResult();result.setScanRequestId(UUID.randomUUID().toString());assertFalse(completion.handleRemoteCompletedScan(claim,result)); + assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void missingCurrentPolicyRequiresReviewAndCannotSchedule() { + var claim=attached(30000);when(policy.currentPolicyVersion()).thenReturn(null); + assertTrue(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals("REVIEW",saved().getVerdict()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult())); + } + @Test void terminalRemoteFailureDoesNotCreateAnotherLocalScanAttempt() { + var claim=attached(30000);var result=cleanResult();var e=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),e.contentSha256(),true,false,"RATE_LIMITED",e.entryHashes())); + result.setVerdict("REVIEW");result.setStatus(ScanStatus.SUSPICIOUS); + assertTrue(completion.handleRemoteCompletedScan(claim,result));assertEquals("COMPLETED",saved().getScanState()); + assertEquals(binding.requestId(),saved().getScanRequestId());assertEquals(job,saved().getRemoteReview().jobId()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void incompleteRemoteEvidenceRemainsManualReview() { + var claim=attached(30000);var result=cleanResult();var e=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),e.contentSha256(),false,false,"INCOMPLETE",e.entryHashes()));result.setArtifactVerified(false);result.setVerdict("REVIEW");result.setStatus(ScanStatus.SUSPICIOUS); + assertTrue(completion.handleRemoteCompletedScan(claim,result));assertEquals("REVIEW",saved().getVerdict());assertFalse(ArtifactClearancePolicy.cleared(saved())); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + } + void queuedAgain() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().unset("versions.0.scanResult.remoteReview").set("versions.0.scanResult.scanState","QUEUED"),Project.class); + } + void configuration(HttpExchange e)throws Exception { + byte[] data=mapper.writeValueAsBytes(Map.of("policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + e.sendResponseHeaders(200,data.length);e.getResponseBody().write(data); + } + RemoteReviewBootstrap bootstrap(RemoteReviewPersistence persistence) {return new RemoteReviewBootstrap(persistence,client,step);} + @Test void bootstrapBindsQueuedRequestBeforeUploadAndReusesRetainedConfiguration() { + queuedAgain();var configurations=new AtomicInteger(); + route(e->{if(e.getRequestURI().getPath().endsWith("configuration")){configurations.incrementAndGet();configuration(e);} + else if(e.getRequestMethod().equals("POST"))reply(e,202,"QUEUED");else reply(e,404,"AWAITING_UPLOAD");}); + var candidate=new RemoteReviewDiscovery(mongo).page(null,16).candidates().getFirst(); + var boot=bootstrap(new RemoteReviewPersistence(mongo));assertEquals("RECORDED",boot.advance(candidate.projectId(),candidate.versionId(),candidate.attempt(),candidate.requestId()).state()); + assertEquals(job,saved().getRemoteReview().jobId());assertEquals("READY",boot.prepare(project,"v",1,binding.requestId()).state()); + assertEquals(origin,saved().getRemoteReview().origin());assertEquals(1,identityGets.get());assertEquals(1,configurations.get());assertEquals(1,posts.get()); + } + @Test void bootstrapRecoversCommittedBindingAcknowledgementLoss() { + queuedAgain();route(this::configuration);var persistence=spy(new RemoteReviewPersistence(mongo)); + doAnswer(i->{assertTrue((Boolean)i.callRealMethod());throw new IllegalStateException("Lost acknowledgement after committed binding");}).when(persistence).bind(any(),any()); + var prepared=bootstrap(persistence).prepare(project,"v",1,binding.requestId());assertEquals("READY",prepared.state());assertEquals(binding,prepared.binding()); + assertEquals(prepared.binding(),saved().getRemoteReview());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void changedContextDuringConfigurationCannotCreateBindingOrUpload() { + queuedAgain();route(e->{change("manifestVersion","changed");configuration(e);}); + assertEquals("NO_WORK",bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId()).state()); + assertNull(saved().getRemoteReview());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void missingBindingStopsDiscoveryWithoutRemoteWork() { + change("scanResult.remoteReview",null); + var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.advance(project,"v",1,binding.requestId()).state()); + assertEquals(ScanStatus.FAILED,saved().getStatus());assertEquals("REMOTE_BINDING_MISSING",saved().getScanState()); + assertEquals(binding.requestId(),saved().getScanRequestId());assertEquals(1,saved().getScanAttempt()); + assertFalse(ArtifactClearancePolicy.cleared(saved())); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + assertEquals("NO_WORK",boot.advance(project,"v",1,binding.requestId()).state()); + assertEquals(0,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void mismatchedAttachedBindingPreservesBlockAndRejectsOldCompletion() { + var claim=attached(30000);change("manifestVersion","changed");change("scanResult.verdict","BLOCK"); + change("scanResult.riskScore",87); + assertEquals("UNAVAILABLE",bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId()).state()); + assertEquals("REMOTE_BINDING_MISMATCH",saved().getScanState());assertEquals(ScanStatus.FAILED,saved().getStatus()); + assertEquals("BLOCK",saved().getVerdict());assertEquals(87,saved().getRiskScore()); + assertEquals(job,saved().getRemoteReview().jobId());assertNull(saved().getRemotePoll()); + assertFalse(polls.isCurrent(claim));assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult())); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + assertEquals(0,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void validOrSupersededBindingCannotBeFailed() { + var persistence=new RemoteReviewPersistence(mongo); + assertNull(persistence.finishBrokenBinding(project,"v",1,binding.requestId())); + change("manifestVersion","changed"); + assertNull(persistence.finishBrokenBinding(project,"v",1,UUID.randomUUID().toString())); + assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"repair","request","metadata","duplicate"}) + void concurrentChangeBetweenSnapshotAndFailureWriteIsNotOverwritten(String mutation) { + change("manifestVersion","changed");var intercepted=new AtomicBoolean(); + var settings=com.mongodb.MongoClientSettings.builder().applyConnectionString(new com.mongodb.ConnectionString( + "mongodb://127.0.0.1:"+System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030")+"/?serverSelectionTimeoutMS=3000")) + .addCommandListener(new com.mongodb.event.CommandListener() { + @Override public void commandStarted(com.mongodb.event.CommandStartedEvent event) { + if(!event.getCommandName().equals("update") || !intercepted.compareAndSet(false,true))return; + switch(mutation) { + case "repair" -> change("manifestVersion",null); + case "request" -> change("scanResult.scanRequestId",UUID.randomUUID().toString()); + case "metadata" -> change("unrecognizedMetadata","preserve this concurrent field"); + case "duplicate" -> { + var collection=mongo.getCollection("projects");var root=collection.find().first(); + collection.updateOne(new Document("_id",root.get("_id")),new Document("$push",new Document("versions",root.getList("versions",Document.class).getFirst()))); + } + } + } + }).build(); + try(var raceClient=MongoClients.create(settings)) { + assertNull(new RemoteReviewPersistence(new MongoTemplate(raceClient,database)).finishBrokenBinding(project,"v",1,binding.requestId())); + } + assertTrue(intercepted.get());assertEquals("REMOTE_REVIEW",saved().getScanState());assertEquals(ScanStatus.SCANNING,saved().getStatus()); + if(mutation.equals("repair"))assertNotNull(new RemoteReviewPersistence(mongo).retained(project,"v",1,binding.requestId())); + if(mutation.equals("metadata"))assertEquals("preserve this concurrent field",mongo.getCollection("projects").find().first().getList("versions",Document.class).getFirst().getString("unrecognizedMetadata")); + if(mutation.equals("duplicate"))assertEquals(2,mongo.findById(project,Project.class).getVersions().size()); + } + @Test void retainedContextMismatchNeverFetchesAnotherConfiguration() { + change("manifestVersion","changed");route(this::configuration); + assertEquals("UNAVAILABLE",bootstrap(new RemoteReviewPersistence(mongo)).prepare(project,"v",1,binding.requestId()).state());assertEquals(0,gets.get()); + } + @Test void unsupportedContextAndMissingRequestRemainExplicitWithoutHttp() { + queuedAgain();change("overrideFileUrl","unreviewed.zip");route(this::configuration);var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.prepare(project,"v",1,binding.requestId()).state());assertEquals("NO_WORK",boot.prepare(project,"v",1,null).state());assertEquals(0,gets.get()); + } + @Test void unsupportedSetupPreservesRequestAndFindingsAndStopsAutomaticPolling() { + queuedAgain();change("overrideFileUrl","unreviewed.zip");change("scanResult.manualRescan",true); + change("scanResult.verdict","BLOCK");change("scanResult.riskScore",42); + change("scanResult.issues",List.of(new org.bson.Document("type","RetainedFinding"))); + route(this::configuration);var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.advance(project,"v",1,binding.requestId()).state()); + var result=saved();assertEquals(ScanStatus.FAILED,result.getStatus());assertEquals("REMOTE_UNSUPPORTED_CONTEXT",result.getScanState()); + assertEquals(binding.requestId(),result.getScanRequestId());assertEquals(1,result.getScanAttempt());assertTrue(result.isManualRescan()); + assertEquals("BLOCK",result.getVerdict());assertEquals(42,result.getRiskScore());assertEquals("RetainedFinding",result.getIssues().getFirst().getType()); + assertFalse(ArtifactClearancePolicy.cleared(result));assertNull(result.getRemoteReview()); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + assertEquals("NO_WORK",bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId()).state()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("status",ProjectStatus.PUBLISHED),Project.class); + var failures=new net.modtale.service.admin.review.ModerationQueuePageReader(mongo).page(null,25,net.modtale.service.admin.review.ModerationQueuePageReader.Filter.OPERATIONS); + assertEquals(1,failures.items().size());assertEquals("REMOTE_UNSUPPORTED_CONTEXT",failures.items().getFirst().pendingVersion().scan().scanState()); + assertEquals(0,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"context","request","verdict","binding","duplicate"}) + void unsupportedSetupRejectsConcurrentChange(String mutation) { + queuedAgain();change("overrideFileUrl","unreviewed.zip");var persistence=new RemoteReviewPersistence(mongo); + var observed=persistence.current(project,"v",1,binding.requestId());assertNotNull(observed); + switch(mutation) { + case "context" -> change("overrideFileUrl",null); + case "request" -> change("scanResult.scanRequestId",UUID.randomUUID().toString()); + case "verdict" -> change("scanResult.verdict","BLOCK"); + case "binding" -> change("scanResult.remoteReview",binding); + case "duplicate" -> mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().push("versions",observed),Project.class); + } + assertFalse(persistence.finishUnsupportedContext(project,observed));assertEquals(ScanStatus.SCANNING,saved().getStatus()); + } + @Test void supportedOrAlreadyBoundContextCannotBeFinishedAsUnsupported() { + var persistence=new RemoteReviewPersistence(mongo); + assertFalse(persistence.finishUnsupportedContext(project,persistence.current(project,"v",1,binding.requestId()))); + queuedAgain();assertFalse(persistence.finishUnsupportedContext(project,persistence.current(project,"v",1,binding.requestId()))); + assertEquals("QUEUED",saved().getScanState()); + } + @Test void manualRescanModeSurvivesBootstrapAttachmentAndCompletesImmediately() { + queuedAgain();change("scanResult.manualRescan",true); + route(e->{if(e.getRequestURI().getPath().endsWith("configuration"))configuration(e);else reply(e,200,"COMPLETED");}); + var outcome=bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId());assertEquals("APPLIED",outcome.state()); + var version=mongo.findById(project,Project.class).getVersions().getFirst();assertEquals(ProjectVersion.ReviewStatus.APPROVED,version.getReviewStatus()); + assertNotNull(version.getApprovedSecurityEvidence());assertNull(version.getScanResult());assertEquals(0,posts.get()); + } + @Test void requestModeChangeDuringConfigurationRejectsStaleBinding() { + queuedAgain();route(e->{change("scanResult.manualRescan",true);configuration(e);}); + assertEquals("NO_WORK",bootstrap(new RemoteReviewPersistence(mongo)).prepare(project,"v",1,binding.requestId()).state());assertNull(saved().getRemoteReview()); + } + @Test void schedulerDiscoversBootstrapsUploadsAndSchedulesCompletedReview()throws Exception { + queuedAgain();route(e->{if(e.getRequestURI().getPath().endsWith("configuration"))configuration(e); + else if(e.getRequestMethod().equals("POST"))reply(e,202,"COMPLETED");else reply(e,e.getRequestURI().getPath().endsWith("/result")?200:404,"COMPLETED");}); + when(policy.remoteJobsEnabled()).thenReturn(true); + var recovery=mock(ScanRecoveryService.class);var legacyPersistence=mock(ScanPersistenceService.class); + var execution=new ScanExecutionService(policy,storage,r->{throw new AssertionError("Legacy dispatch");},legacyPersistence,completion,recovery); + execution.enqueueBackgroundScan(project,"v",binding.filePath(),"original.zip",false,1,binding.requestId()); + execution.recoverStaleScanningVersions(); + assertEquals("QUEUED",saved().getScanState());assertEquals(binding.requestId(),saved().getScanRequestId()); + verifyNoInteractions(legacyPersistence,recovery);verify(storage,never()).download(any()); + try(var scheduler=new RemoteReviewScheduler(new RemoteReviewDiscovery(mongo),bootstrap(new RemoteReviewPersistence(mongo)),new RemoteReviewScheduler.Settings(2,4,100,2000))) { + scheduler.start();long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(10); + while(mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()!=ProjectVersion.ReviewStatus.SCHEDULED && System.nanoTime(){if(e.getRequestURI().getPath().endsWith("configuration"))configuration(e);else reply(e,404,"AWAITING_UPLOAD");}); + when(storage.downloadBounded(anyString(),anyInt())).thenAnswer(i->{entered.countDown();long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(release.getCount()>0&&System.nanoTime()reply(e,200,state)); + assertEquals("UNAVAILABLE",step.advance(binding).state()); + var result=saved();assertEquals(ScanStatus.FAILED,result.getStatus());assertEquals("REMOTE_"+state,result.getScanState()); + assertEquals("REVIEW",result.getVerdict());assertEquals(binding.requestId(),result.getScanRequestId()); + assertEquals(job,result.getRemoteReview().jobId());assertEquals(state,result.getRemoteStatus().state()); + assertNull(result.getRemotePoll());assertNull(result.getSecurityEvidence());assertFalse(ArtifactClearancePolicy.cleared(result)); + assertEquals(0,result.getRiskScore());assertTrue(result.getIssues().isEmpty());assertTrue(result.getScanTimestamp()>0); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + assertEquals("NO_WORK",new RemoteReviewStep(new RemoteReviewPollStore(mongo),client,storage,completion).advance(result.getRemoteReview()).state()); + assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage);verify(completion,never()).handleRemoteCompletedScan(any(),any()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"HELD","CANCELLED","EXPIRED"}) + void terminalServiceOutcomePreservesSecurityBlockAndFindings(String state) { + change("scanResult.verdict","BLOCK");change("scanResult.riskScore",87); + change("scanResult.issues",List.of(new Document("type","RetainedFinding"))); + route(e->reply(e,200,state));assertEquals("UNAVAILABLE",step.advance(binding).state()); + assertEquals("BLOCK",saved().getVerdict());assertEquals(87,saved().getRiskScore()); + assertEquals("RetainedFinding",saved().getIssues().getFirst().getType());assertEquals(ScanStatus.FAILED,saved().getStatus()); + assertEquals("REMOTE_"+state,saved().getScanState());assertFalse(ArtifactClearancePolicy.cleared(saved())); + assertEquals(binding.requestId(),saved().getScanRequestId());assertEquals(job,saved().getRemoteReview().jobId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("status",ProjectStatus.PUBLISHED),Project.class); + var reader=new net.modtale.service.admin.review.ModerationQueuePageReader(mongo); + for(var filter:List.of(net.modtale.service.admin.review.ModerationQueuePageReader.Filter.SECURITY,net.modtale.service.admin.review.ModerationQueuePageReader.Filter.OPERATIONS)) { + var rows=reader.page(null,25,filter).items();assertEquals(1,rows.size());assertEquals("BLOCK",rows.getFirst().pendingVersion().scan().verdict()); + } + assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"verdict","context","lease"}) + void terminalFailureWriteRejectsChangesAfterLiveSnapshot(String mutation) { + var claim=attached(30000);var intercepted=new AtomicBoolean(); + var settings=com.mongodb.MongoClientSettings.builder().applyConnectionString(new com.mongodb.ConnectionString( + "mongodb://127.0.0.1:"+System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030")+"/?serverSelectionTimeoutMS=3000")) + .addCommandListener(new com.mongodb.event.CommandListener() { + @Override public void commandStarted(com.mongodb.event.CommandStartedEvent event) { + if(!event.getCommandName().equals("update") || !intercepted.compareAndSet(false,true))return; + switch(mutation) { + case "verdict" -> change("scanResult.verdict","BLOCK"); + case "context" -> change("manifestVersion","changed during write"); + case "lease" -> change("scanResult.remotePoll.token",UUID.randomUUID().toString()); + } + } + }).build(); + try(var raceClient=MongoClients.create(settings)) { + assertFalse(new RemoteReviewPollStore(new MongoTemplate(raceClient,database)).finishUnavailable(claim, + new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"))); + } + assertTrue(intercepted.get());assertEquals("REMOTE_REVIEW",saved().getScanState());assertEquals(ScanStatus.SCANNING,saved().getStatus()); + if(mutation.equals("verdict")) { + assertEquals("BLOCK",saved().getVerdict()); + assertTrue(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"))); + assertEquals("BLOCK",saved().getVerdict()); + } + } + @Test void databaseQueueKeepsFailedVersionVisibleBesideScanningSibling() { + route(e->reply(e,200,"HELD"));assertEquals("UNAVAILABLE",step.advance(binding).state()); + var sibling=new ProjectVersion();sibling.setId("scanning-sibling");sibling.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);sibling.setScanResult(scan); + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("status",ProjectStatus.PUBLISHED).push("versions",sibling),Project.class); + var rows=new net.modtale.service.admin.review.ModerationQueuePageReader(mongo).page(null,25).items();assertEquals(1,rows.size());assertEquals("v",rows.getFirst().pendingVersion().id()); + assertEquals("REMOTE_HELD",rows.getFirst().pendingVersion().scan().scanState()); + assertEquals(ScanStatus.FAILED,rows.getFirst().pendingVersion().scan().status()); + } + @Test void expiredTerminalClaimCannotOverwriteOrConsumeAnotherAttempt() { + var claim=attached(1000); + change("scanResult.remotePoll.leaseUntil",new Date(0)); + var status=new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"); + assertFalse(polls.finishUnavailable(claim,status));assertEquals("REMOTE_REVIEW",saved().getScanState()); + var replacement=polls.claim(claim.binding(),30000);assertNotNull(replacement); + assertFalse(polls.finishUnavailable(claim,status));assertTrue(polls.isCurrent(replacement)); + assertTrue(polls.finishUnavailable(replacement,status));assertFalse(polls.finishUnavailable(replacement,status)); + } + @Test void terminalOutcomeRequiresExactJobAndUnchangedContext() { + var claim=attached(30000); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(UUID.randomUUID().toString(),"HELD",true,1,2,"HELD"))); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"COMPLETED",true,1,2,"COMPLETED"))); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"HELD",false,1,2,"HELD"))); + assertFalse(polls.recordStatusAndRelease(claim,new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"),10000)); + change("manifestVersion","changed"); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"))); + assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void lostPostResponseRecoversSameRequestAndRecordsJobWithoutAnotherUpload() { + var accepted=new AtomicBoolean();route(e->{if(e.getRequestMethod().equals("POST")){accepted.set(true);return;}reply(e,accepted.get()?200:404,accepted.get()?"QUEUED":"AWAITING_UPLOAD");}); + assertEquals("RETRY",step.advance(binding).state());assertNull(saved().getRemoteReview().jobId());ready(); + assertEquals("RECORDED",step.advance(saved().getRemoteReview()).state());assertEquals(job,saved().getRemoteReview().jobId());assertEquals(1,posts.get());verify(storage,times(1)).downloadBounded(anyString(),anyInt()); + } + @Test void contextChangeDuringLookupPreventsDownloadAndPost() { + route(e->{change("manifestVersion","changed");reply(e,404,"AWAITING_UPLOAD");}); + assertEquals("SUPERSEDED",step.advance(binding).state());verifyNoInteractions(storage);assertEquals(0,posts.get());assertNull(saved().getRemoteStatus()); + } + @Test void leaseExpiryDuringDownloadPreventsPost() { + route(e->reply(e,404,"AWAITING_UPLOAD"));when(storage.downloadBounded(anyString(),anyInt())).thenAnswer(i->{change("scanResult.remotePoll.leaseUntil",new Date(0));return bytes;}); + assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals(0,posts.get());assertNull(saved().getRemoteReview().jobId()); + } + @Test void changedContextDuringPostCannotAttachRemoteJob() { + route(e->{if(e.getRequestMethod().equals("POST")){change("manifestVersion","changed");reply(e,202,"QUEUED");}else reply(e,404,"AWAITING_UPLOAD");}); + assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals(1,posts.get());assertNull(saved().getRemoteReview().jobId());assertNull(saved().getRemoteStatus()); + } + @Test void knownUploadingJobCanResumeAbandonedUploadWithSameIdentity() { + var state=new AtomicReference<>("UPLOADING");route(e->{if(e.getRequestMethod().equals("POST"))reply(e,202,"QUEUED");else reply(e,200,state.get());}); + assertEquals("UPLOADING",step.advance(binding).remoteState());verifyNoInteractions(storage);ready();state.set("AWAITING_UPLOAD"); + assertEquals("QUEUED",step.advance(saved().getRemoteReview()).remoteState());assertEquals(job,saved().getRemoteReview().jobId());assertEquals(1,posts.get()); + } + @Test void competingStepCannotDispatchUnderExistingLiveClaim() { + assertNotNull(polls.claim(binding,120000));route(e->reply(e,404,"AWAITING_UPLOAD"));assertEquals("NO_WORK",step.advance(binding).state());assertEquals(0,gets.get());verifyNoInteractions(storage); + } + @Test void legacyOriginRoutesOnceToOperationsWithoutNetworkOrBackfill() { + change("scanResult.remoteReview.origin",null);change("scanResult.verdict","BLOCK");var old=saved().getRemoteReview(); + route(e->reply(e,200,"COMPLETED"));var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.advance(project,"v",1,binding.requestId()).state());assertEquals("REMOTE_ORIGIN_UNVERIFIED",saved().getScanState());assertEquals(ScanStatus.FAILED,saved().getStatus()); + assertEquals("BLOCK",saved().getVerdict());assertEquals(old,saved().getRemoteReview());assertNull(saved().getRemoteReview().origin());assertFalse(saved().isArtifactVerified()); + assertEquals("NO_WORK",boot.advance(project,"v",1,binding.requestId()).state());assertEquals(0,gets.get());assertEquals(0,posts.get());assertEquals(0,identityGets.get());verifyNoInteractions(storage); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void legacyWriterWinningInitialBindingRoutesImmediatelyEvenAfterLostAcknowledgement(boolean lostAcknowledgement) { + queuedAgain();change("scanResult.verdict","BLOCK");route(this::configuration); + var persistence=spy(new RemoteReviewPersistence(mongo)); + var legacy=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(), + binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),binding.reviewConfigSha256(),job,binding.manualRescan()); + doAnswer(i->{change("scanResult.remoteReview",legacy);change("scanResult.scanState","REMOTE_REVIEW"); + if(lostAcknowledgement)throw new IllegalStateException("Unknown binding acknowledgement");return i.callRealMethod();}).when(persistence).bind(any(),any()); + var boot=bootstrap(persistence);var prepared=boot.prepare(project,"v",1,binding.requestId()); + assertEquals("UNAVAILABLE",prepared.state());assertNull(prepared.binding()); + assertEquals(ScanStatus.FAILED,saved().getStatus());assertEquals("REMOTE_ORIGIN_UNVERIFIED",saved().getScanState()); + assertEquals(legacy,saved().getRemoteReview());assertEquals("BLOCK",saved().getVerdict());assertFalse(saved().isArtifactVerified()); + assertEquals("NO_WORK",boot.advance(project,"v",1,binding.requestId()).state()); + assertEquals(1,identityGets.get());assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + } + @Test void remoteContextConflictPreservesBindingAndBlockWithoutPollingAgain() { + change("scanResult.verdict","BLOCK");route(e->reply(e,409,"QUEUED"));var before=saved().getRemoteReview(); + assertEquals("UNAVAILABLE",step.advance(binding).state());assertEquals("REMOTE_CONTEXT_CONFLICT",saved().getScanState());assertEquals(before,saved().getRemoteReview());assertEquals("BLOCK",saved().getVerdict()); + assertEquals("NO_WORK",step.advance(binding).state());assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void aConflictArrivingAfterLocalContextChangeCannotOverwriteNewState() { + route(e->{change("manifestVersion","concurrent");reply(e,409,"QUEUED");});assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void changingOriginAfterCompletionSnapshotRejectsTheFinalWrite() { + var claim=attached(30000);when(policy.currentPolicyVersion()).thenAnswer(i->{change("scanResult.remoteReview.origin.deploymentId","22222222-2222-2222-2222-222222222222");return binding.policyVersion();}); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals("REMOTE_REVIEW",saved().getScanState());assertFalse(saved().isArtifactVerified()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReplacementSecurityHoldTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReplacementSecurityHoldTest.java new file mode 100644 index 000000000..176c771dc --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReplacementSecurityHoldTest.java @@ -0,0 +1,63 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.issue.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReplacementSecurityHoldTest { + RemoteReviewStepIntegrationTest f=new RemoteReviewStepIntegrationTest();String hold=UUID.randomUUID().toString(); + @BeforeEach void setup()throws Exception {f.setup(System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031"),true);} + @AfterEach void cleanup(){f.cleanup();} + ProjectVersion version(){return f.mongo.findById(f.project,Project.class).getVersions().getFirst();} + + @Test void aCleanRemoteCompletionCannotSilentlyClearARetainedBlock() { + f.change("replacementSecurityHold",hold);var claim=f.attached(30000); + assertTrue(f.completion.handleRemoteCompletedScan(claim,f.cleanResult())); + var version=version();assertEquals(hold,version.getReplacementSecurityHold()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,version.getReviewStatus());assertEquals("BLOCK",version.getScanResult().getVerdict()); + assertNull(version.getScheduledPublishDate());assertFalse(ArtifactClearancePolicy.boundToVersion(version)); + } + + @Test void directAutomaticApprovalPersistenceCannotBypassTheHold() { + f.change("replacementSecurityHold",hold);var claim=f.attached(30000);var version=version(); + var persistence=new ScanPersistenceService(f.mongo,mock(net.modtale.repository.project.ProjectRepository.class),mock(net.modtale.service.project.query.ProjectService.class)); + for(var action:List.of(ScanRoutingService.RoutingAction.SCHEDULE,ScanRoutingService.RoutingAction.APPROVE_NOW)) + assertFalse(persistence.applyRemoteScanOutcome(claim,version.getScanResult().getRemotePoll(),f.cleanResult(),new ScanRoutingService.RoutingDecision(action,1),version)); + assertEquals("REMOTE_REVIEW",f.saved().getScanState()); + } + + @Test void holdAddedAfterCompletionReadFencesTheStaleWrite() { + var claim=f.attached(30000); + when(f.policy.currentPolicyVersion()).thenAnswer(call->{f.change("replacementSecurityHold",hold);return f.binding.policyVersion();}); + assertFalse(f.completion.handleRemoteCompletedScan(claim,f.cleanResult())); + assertEquals(hold,version().getReplacementSecurityHold());assertEquals("REMOTE_REVIEW",f.saved().getScanState()); + } + + @Test void manualApprovalAndPruningCannotEraseTheHeldEvidence() { + f.change("replacementSecurityHold",hold);var version=version();var before=f.saved().getScanRequestId(); + var persistence=new VersionReviewPersistence(f.mongo);var snapshot=persistence.capture(f.project,"v",VersionReviewSnapshot.token(version)); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);version.setScanResult(null); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->persistence.apply(snapshot,version)); + var approval=new SecurityIssueApprovalService(mock(SecurityIssueClassificationService.class)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->approval.markIssuesAcceptedForApprovedVersion(version())); + var project=f.mongo.findById(f.project,Project.class);project.getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertEquals(0,approval.pruneApprovedScanResults(project));assertNotNull(project.getVersions().getFirst().getScanResult()); + assertEquals(before,f.saved().getScanRequestId()); + } + + @Test void tokensBindTheHoldAndPointerButNetworkPayloadsCannotSetThem()throws Exception { + var version=version();String before=VersionReviewSnapshot.token(version),rescan=VersionReviewSnapshot.rescanToken(version); + version.setReplacementSecurityHold(hold);assertNotEquals(before,VersionReviewSnapshot.token(version));assertNotEquals(rescan,VersionReviewSnapshot.rescanToken(version)); + String held=VersionReviewSnapshot.token(version);version.setReviewReplacement(new ProjectVersion.ReviewReplacement(UUID.randomUUID().toString(),"a".repeat(64),UUID.randomUUID().toString())); + assertNotEquals(held,VersionReviewSnapshot.token(version)); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + var decoded=mapper.readValue("{\"replacementSecurityHold\":\""+hold+"\"}",ProjectVersion.class);assertNull(decoded.getReplacementSecurityHold()); + assertFalse(mapper.writeValueAsString(version).contains("replacementSecurityHold")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RetainedRemoteReviewTest.java b/backend/src/test/java/net/modtale/service/security/scan/RetainedRemoteReviewTest.java new file mode 100644 index 000000000..9284fc8f2 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RetainedRemoteReviewTest.java @@ -0,0 +1,75 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.issue.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class RetainedRemoteReviewTest { + RemoteReviewIsolationIntegrationTest base=new RemoteReviewIsolationIntegrationTest(); + RemoteReviewBinding binding; + @BeforeEach void setup()throws Exception {base.setup();binding=base.fixture.attached(30000).binding();} + @AfterEach void cleanup(){base.cleanup();} + Project project(){return base.fixture.mongo.findById(base.fixture.project,Project.class);} + + @ParameterizedTest @ValueSource(booleans={false,true}) + void manualApprovalWritersRetainTheOriginalBindingEvenWhenTheCallerPrunesIt(boolean projectWriter) { + var f=base.fixture;var project=project();var version=project.getVersions().getFirst(); + if(projectWriter) { + var writer=new ProjectReviewPersistence(f.mongo);var snapshot=writer.capture(f.project,ProjectReviewSnapshot.token(project)); + var reviewed=snapshot.project().getVersions().getFirst();reviewed.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);reviewed.setScanResult(null); + assertTrue(writer.apply(snapshot,"v")); + } else { + var writer=new VersionReviewPersistence(f.mongo);var snapshot=writer.capture(f.project,"v",VersionReviewSnapshot.token(version)); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);version.setScanResult(null); + assertTrue(writer.apply(snapshot,version)); + } + var approved=project().getVersions().getFirst();assertNull(approved.getScanResult());assertEquals(binding,approved.getRetainedRemoteReview()); + assertEquals(binding,new ReviewRemoteTargetReader(f.mongo).capture(base.raw().get("_id"),0,"v").binding()); + var rescans=new VersionReviewPersistence(f.mongo);var captured=rescans.captureForRescan(f.project,"v",VersionReviewSnapshot.rescanToken(approved)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->rescans.queueRescan(captured,new ScanResult())); + assertEquals(binding,project().getVersions().getFirst().getRetainedRemoteReview()); + } + + @Test void scheduledPublicationRetainsTheCompletedRemoteReference() { + var f=base.fixture;var scan=f.cleanResult();scan.setRemoteReview(binding); + f.change("scanResult",f.mongo.getConverter().convertToMongoType(scan)); + f.change("reviewStatus","SCHEDULED");f.change("scheduledPublishDate","2000-01-01T00:00:00"); + var release=new net.modtale.service.project.lifecycle.ScheduledReleaseExecutionService(f.mongo, + mock(net.modtale.service.project.query.ProjectService.class),mock(net.modtale.service.communication.ProjectNotificationService.class),f.analysis,f.policy); + assertEquals(1,release.publishDueVersions(project(),java.time.LocalDateTime.now()).size()); + var approved=project().getVersions().getFirst();assertEquals(ProjectVersion.ReviewStatus.APPROVED,approved.getReviewStatus()); + assertNull(approved.getScanResult());assertEquals(binding,approved.getRetainedRemoteReview()); + } + + @Test void pruningRetainsHistoryAndKeepsItPrivateAndSnapshotBound()throws Exception { + var version=project().getVersions().getFirst();var service=new SecurityIssueApprovalService(mock(SecurityIssueClassificationService.class)); + version.getScanResult().setIssues(java.util.List.of());service.markIssuesAcceptedForApprovedVersion(version); + assertNull(version.getScanResult());assertEquals(binding,version.getRetainedRemoteReview()); + String token=VersionReviewSnapshot.token(version),rescan=VersionReviewSnapshot.rescanToken(version); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper();assertFalse(mapper.writeValueAsString(version).contains("retainedRemoteReview")); + assertNull(mapper.readValue("{\"retainedRemoteReview\":"+mapper.writeValueAsString(binding)+"}",ProjectVersion.class).getRetainedRemoteReview()); + version.setRetainedRemoteReview(null);assertNotEquals(token,VersionReviewSnapshot.token(version));assertNotEquals(rescan,VersionReviewSnapshot.rescanToken(version)); + } + + @ParameterizedTest @ValueSource(strings={"attempt","job","origin","artifact","newScan"}) + void retainedReferencesMustRemainStrictAndCannotRetargetNewScans(String corruption) { + var f=base.fixture;var raw=base.raw().getList("versions",Document.class).getFirst().get("scanResult",Document.class).get("remoteReview",Document.class); + f.change("retainedRemoteReview",raw);f.change("scanResult",null); + switch(corruption) { + case "attempt" -> f.change("retainedRemoteReview.attempt",1.0); + case "job" -> f.change("retainedRemoteReview.jobId",null); + case "origin" -> f.change("retainedRemoteReview.origin",null); + case "artifact" -> f.change("hash","d".repeat(64)); + case "newScan" -> f.change("scanResult",new Document("scanState","QUEUED")); + } + assertThrows(IllegalStateException.class,()->new ReviewRemoteTargetReader(f.mongo).capture(base.raw().get("_id"),0,"v")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java new file mode 100644 index 000000000..1600f08f4 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java @@ -0,0 +1,107 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.*; +import net.modtale.controller.admin.ReviewCancellationController; +import net.modtale.service.admin.review.*; +import net.modtale.service.user.account.AccountService; +import net.modtale.model.user.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.context.annotation.*; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.*; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewCancellationApiIntegrationTest { + @Configuration @EnableMethodSecurity static class MethodSecurity {} + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));} + } + ReviewOrphanCancellationJournalTest base=new ReviewOrphanCancellationJournalTest(); + AnnotationConfigApplicationContext context;MockMvc mvc;ObjectMapper mapper=new ObjectMapper(); + AtomicReference account=new AtomicReference<>();AtomicInteger cancellations=new AtomicInteger(),reads=new AtomicInteger(); + String prefix="/api/v1/admin/verification/cancellations"; + @BeforeEach void setup()throws Exception { + base.setup();account.set(user()); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("actor",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_READ"),new SimpleGrantedAuthority("PROJECT_VERSION_RESCAN")))); + open(); + } + User user(){var u=new User();u.setId("actor");u.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ,AdminPermission.PROJECT_VERSION_RESCAN));return u;} + void open() { + var f=base.fixture.fixture;var accounts=mock(AccountService.class);when(accounts.getCurrentUser(any())).thenAnswer(i->account.get()); + var journal=base.create(f.mongo,Clock.systemUTC()); + var preparation=new ReviewRepairPreparation(base.fixture.archive,base.fixture.reader,Clock.systemUTC(),1,60000); + var workflow=new ReviewRepairWorkflow(preparation,base.fixture.isolation,1); + var executor=new ReviewOrphanCancellationExecutor(journal,f.client,1,Duration.ofSeconds(5)); + var reconciler=new ReviewCancellationReconciler(f.mongo,journal,f.client,1,Duration.ofSeconds(5)); + reconciler.initializeDiscovery(); + context=new AnnotationConfigApplicationContext();context.getEnvironment().getPropertySources().addFirst(new org.springframework.core.env.MapPropertySource("test",Map.of("app.warden.repair.cancellation.enabled","true")));context.register(MethodSecurity.class); + context.registerBean("apiSecurity",Permissions.class,Permissions::new); + context.registerBean(ReviewRepairWorkflow.class,()->workflow);context.registerBean(ReviewOrphanCancellationExecutor.class,()->executor);context.registerBean(ReviewCancellationReconciler.class,()->reconciler); + context.registerBean(ReviewCancellationAccess.class,()->new ReviewCancellationAccess(accounts,workflow,journal,executor,reconciler)); + context.registerBean(ReviewCancellationController.class,()->new ReviewCancellationController(context.getBean(ReviewCancellationAccess.class))); + context.refresh();mvc=MockMvcBuilders.standaloneSetup(context.getBean(ReviewCancellationController.class)).build(); + } + @AfterEach void cleanup(){if(context!=null)context.close();SecurityContextHolder.clearContext();base.cleanup();} + JsonNode postJson(String route,Object body)throws Exception { + var response=mvc.perform(post(prefix+route).contentType("application/json").content(mapper.writeValueAsBytes(body))) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andReturn(); + return mapper.readTree(response.getResponse().getContentAsByteArray()); + } + JsonNode getJson(String route)throws Exception { + return mapper.readTree(mvc.perform(get(prefix+route)).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andReturn().getResponse().getContentAsByteArray()); + } + JsonNode prepare()throws Exception{return postJson("/prepare",Map.of("isolationId",base.fixture.prepared.id()));} + @Test void duplicateHttpRequestsAndRestartRecoverOriginalAndLaterOutcomesWithoutRemoteRedispatch()throws Exception { + var f=base.fixture.fixture;var before=base.fixture.raw(); + f.route(e->{if(e.getRequestMethod().equals("DELETE")){cancellations.incrementAndGet();e.close();}else{assertEquals("GET",e.getRequestMethod());f.reply(e,200,reads.incrementAndGet()==1?"CANCELLED":"COMPLETED");}}); + var preview=getJson("/targets/"+base.fixture.prepared.id());assertEquals(f.job,preview.path("jobId").asText()); + var original=prepare();assertEquals(preview.path("targetSha256"),original.path("targetSha256")); + var executed=postJson("/execute",original);assertEquals("UNKNOWN",executed.path("state").asText()); + assertEquals(executed,postJson("/execute",original));assertEquals(1,cancellations.get()); + var initial=getJson("/operations/"+base.fixture.prepared.id());assertEquals(executed.path("receipt"),initial); + String id=UUID.randomUUID().toString();var check=Map.of("id",id,"original",original); + var observed=postJson("/checks",check);assertEquals("CANCELLED",observed.path("observation").path("status").path("state").asText()); + assertEquals(observed,postJson("/checks",check));assertEquals(observed,postJson("/checks/receipt",check));assertEquals(1,reads.get()); + context.close();open();assertEquals(initial,getJson("/operations/"+base.fixture.prepared.id()));assertEquals(observed,postJson("/checks/receipt",check)); + assertEquals(executed,postJson("/execute",original));assertEquals(observed,postJson("/checks",check));assertEquals(1,cancellations.get());assertEquals(1,reads.get()); + var history=postJson("/checks/history",Map.of("original",original,"limit",10)); + assertEquals(id,history.path("items").get(0).path("id").asText());assertEquals(1,history.path("items").size()); + assertEquals(1,reads.get());assertEquals(1,cancellations.get()); + var second=postJson("/checks",Map.of("id",UUID.randomUUID().toString(),"original",original));assertEquals("COMPLETED",second.path("observation").path("status").path("state").asText()); + assertEquals(initial,getJson("/operations/"+base.fixture.prepared.id()));assertEquals(observed,postJson("/checks/receipt",check)); + assertEquals(2,f.mongo.getCollection(ReviewCancellationReconciler.COLLECTION).countDocuments());assertEquals(before,base.fixture.raw()); + assertEquals("BLOCK",f.saved().getVerdict());assertEquals(0,f.identityGets.get());verifyNoInteractions(f.storage); + } + @Test void storedAccountRevocationAfterDispatchCannotReturnAuthorityOrCauseAnotherCancellation()throws Exception { + var f=base.fixture.fixture;var original=prepare(); + f.route(e->{assertEquals("DELETE",e.getRequestMethod());cancellations.incrementAndGet();account.set(null);f.reply(e,200,"CANCELLED");}); + mvc.perform(post(prefix+"/execute").contentType("application/json").content(mapper.writeValueAsBytes(original))) + .andExpect(status().isForbidden()).andExpect(content().string(""));assertEquals(1,cancellations.get()); + account.set(user());var recovered=getJson("/operations/"+base.fixture.prepared.id());assertEquals("DISPATCHING",recovered.path("state").asText()); + assertEquals("DISPATCHING",postJson("/execute",original).path("state").asText());assertEquals(1,cancellations.get()); + } + @Test void forgedIntentAndFreshAccountDenialCannotCreateRemoteWork()throws Exception { + var f=base.fixture.fixture;f.route(e->{assertEquals("DELETE",e.getRequestMethod());cancellations.incrementAndGet();f.reply(e,200,"CANCELLED");});var original=prepare(); + var forged=original.deepCopy();((com.fasterxml.jackson.databind.node.ObjectNode)forged).put("targetSha256","f".repeat(64)); + // Unknown execution response carries no claim; the retained intent still cannot be changed. + assertEquals("UNKNOWN",postJson("/execute",forged).path("state").asText());assertEquals(0,cancellations.get()); + var denied=user();denied.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ));account.set(denied); + mvc.perform(get(prefix+"/targets/"+base.fixture.prepared.id())).andExpect(status().isForbidden()); + mvc.perform(post(prefix+"/execute").contentType("application/json").content(mapper.writeValueAsBytes(original))).andExpect(status().isForbidden()); + assertEquals(0,cancellations.get());assertEquals("PREPARED",base.operations().find().first().getString("state"));assertEquals(0,f.mongo.getCollection(ReviewCancellationReconciler.COLLECTION).countDocuments()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java new file mode 100644 index 000000000..ab22e82fa --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java @@ -0,0 +1,141 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewCancellationReconcilerTest { + ReviewOrphanCancellationJournalTest base=new ReviewOrphanCancellationJournalTest();RemoteReviewStepIntegrationTest f; + ReviewOrphanCancellationJournal.Prepared original;ReviewCancellationReconciler reconciler;String id=UUID.randomUUID().toString();AtomicInteger reads=new AtomicInteger(); + @BeforeEach void setup()throws Exception { + base.setup();f=base.fixture.fixture;original=base.prepare();var claim=base.journal.claim(original,"actor",()->true);assertTrue(base.journal.beginDispatch(claim,"actor",()->true)>0); + assertEquals("UNKNOWN",base.journal.record(claim,"actor",new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,503),()->true).state()); + reconciler=create(f.mongo,Duration.ofSeconds(5)); + } + @AfterEach void cleanup(){reconciler.close();base.cleanup();} + ReviewCancellationReconciler create(MongoTemplate mongo,Duration budget){return new ReviewCancellationReconciler(mongo,base.journal,f.client,1,budget);} + MongoCollection observations(){return f.mongo.getCollection(ReviewCancellationReconciler.COLLECTION);} + ReviewCancellationReconciler.Receipt check(){return reconciler.check(id,original,"actor",()->true);} + void route(int code,String state){f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();f.reply(e,code,state);});} + @Test void aLaterStatusPreservesTheOriginalUnknownOutcomeAndSurvivesRecreation() { + route(200,"CANCELLED");var before=base.operations().find().first();var project=base.fixture.raw();var result=check(); + assertEquals("OBSERVED",result.state());assertEquals("CANCELLED",result.observation().status().state());assertNotNull(result.receivedAt()); + assertEquals(result,create(f.mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true));assertEquals(result,reconciler.receipt(id,original,"actor",()->true)); + assertEquals(1,reads.get());assertEquals(0,f.posts.get());assertEquals(0,f.identityGets.get());assertEquals(before,base.operations().find().first());assertEquals(project,base.fixture.raw());verifyNoInteractions(f.storage); + assertNull(base.journal.claim(original,"actor",()->true)); + } + @Test void currentVersionEditsOrDeletionCannotRetargetTheOriginalStatusRequest() { + f.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString());f.mongo.getCollection("projects").deleteMany(new Document()); + route(200,"COMPLETED");assertEquals(f.job,check().observation().status().jobId());assertEquals(1,reads.get()); + } + @Test void distinctExplicitChecksKeepBothObservationsWithoutOverwritingHistory() { + f.route(e->{assertEquals("GET",e.getRequestMethod());f.reply(e,200,reads.incrementAndGet()==1?"QUEUED":"CANCELLED");}); + var first=check();var second=reconciler.check(UUID.randomUUID().toString(),original,"actor",()->true); + assertEquals("QUEUED",first.observation().status().state());assertEquals("CANCELLED",second.observation().status().state());assertEquals(first,reconciler.receipt(id,original,"actor",()->true)); + assertEquals(2,observations().countDocuments());assertEquals("UNKNOWN",base.operations().find().first().getString("state")); + } + @ParameterizedTest @ValueSource(ints={404,409,401,403,429,503}) + void statusFailuresAreRetainedWithoutCancellingOrRepeatingTheRead(int code) { + route(code,"QUEUED");var result=check();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==503?"UNKNOWN":"UNAVAILABLE"; + assertEquals(kind,result.observation().kind());assertEquals(code,result.observation().httpStatus());assertEquals(result,check());assertEquals(1,reads.get()); + assertEquals("UNKNOWN",base.operations().find().first().getString("state")); + } + @Test void permissionAndOriginalIntentValidationPrecedeReservation() { + assertThrows(SecurityException.class,()->reconciler.check(id,original,"actor",()->false));assertThrows(SecurityException.class,()->reconciler.check(id,original,"other",()->true)); + var forged=new ReviewOrphanCancellationJournal.Prepared(original.isolationId(),"f".repeat(64),original.createdAt(),original.expiresAt()); + assertThrows(IllegalStateException.class,()->reconciler.check(id,forged,"actor",()->true));assertEquals(0,observations().countDocuments());assertEquals(0,reads.get()); + } + @Test void unstartedCancellationCannotCreateAnObservation() { + var record=base.operations().find().first();base.operations().replaceOne(new Document("_id",original.isolationId()),new Document("_id",original.isolationId()).append("intent",record.get("intent")).append("state","PREPARED")); + assertThrows(IllegalStateException.class,this::check);assertEquals(0,observations().countDocuments()); + } + @Test void competingChecksAndLocalAdmissionDoNotDuplicateARead()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1);f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();arrived.countDown();release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}); + try(var pool=Executors.newSingleThreadExecutor();var replica=create(f.mongo,Duration.ofSeconds(5))) { + var first=pool.submit(this::check);assertTrue(arrived.await(3,TimeUnit.SECONDS));assertThrows(IllegalStateException.class,()->reconciler.check(UUID.randomUUID().toString(),original,"actor",()->true)); + assertEquals("READING",replica.check(id,original,"actor",()->true).state());release.countDown();assertEquals("OBSERVED",first.get(4,TimeUnit.SECONDS).state());assertEquals(1,reads.get()); + }finally{release.countDown();} + } + MongoCollection intercepted(MongoTemplate template) { + var ops=spy(observations().withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true))); + doReturn(ops).when(template).getCollection(ReviewCancellationReconciler.COLLECTION);doReturn(ops).when(ops).withReadPreference(any());doReturn(ops).when(ops).withReadConcern(any());doReturn(ops).when(ops).withWriteConcern(any());doReturn(ops).when(ops).withTimeout(anyLong(),any());return ops; + } + @Test void lostReservationAcknowledgementRecoversOnlyTheSameReadToken() { + var mongo=spy(f.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost reservation acknowledgement");}).when(ops).insertOne(any(Document.class)); + route(200,"CANCELLED");var result=create(mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true);assertEquals("OBSERVED",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void lostReadGateAcknowledgementCannotStartAnHttpRequest() { + var mongo=spy(f.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost read gate acknowledgement");}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + route(200,"CANCELLED");var result=create(mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true);assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(0,reads.get()); + } + @Test void lostObservationAcknowledgementReadsBackWithoutAnotherStatusCall() { + var mongo=spy(f.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost observation acknowledgement");}).when(ops).updateOne(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.UpdateOptions.class)); + route(200,"CANCELLED");var result=create(mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true);assertEquals("OBSERVED",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void droppedReplyProducesOneReadAndUnknownObservation() { + f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();e.close();});var result=check();assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void tamperedObservationCannotBeReadOrOverwrittenAsAValidResult() { + route(200,"CANCELLED");check();observations().updateOne(new Document("_id",id),new Document("$set",new Document("observation.status.jobId",UUID.randomUUID().toString()))); + var stored=observations().find().first();assertThrows(IllegalStateException.class,()->reconciler.receipt(id,original,"actor",()->true));assertThrows(IllegalStateException.class,this::check); + assertEquals(stored,observations().find().first());assertEquals(1,reads.get()); + } + @Test void permissionRevocationAfterReplyLeavesAnUnresolvedReadWithoutReplay() { + var allowed=new AtomicBoolean(true);f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();allowed.set(false);f.reply(e,200,"CANCELLED");}); + assertThrows(SecurityException.class,()->reconciler.check(id,original,"actor",allowed::get));assertEquals("READING",check().state());assertEquals(1,reads.get()); + } + @Test void shutdownAndInvalidIdsDoNotAllocateObservationRecords() { + assertThrows(IllegalArgumentException.class,()->reconciler.check("bad",original,"actor",()->true));reconciler.close();assertThrows(IllegalStateException.class,this::check); + assertThrows(SecurityException.class,()->reconciler.check(id,original,"actor",()->false));assertEquals(0,observations().countDocuments()); + } + @Test void remainingReadBudgetBoundsWaitingWithoutRepeatingTheGet()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(1); + f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();arrived.countDown();try{release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}finally{done.countDown();}}); + try(var bounded=create(f.mongo,Duration.ofSeconds(1));var pool=Executors.newSingleThreadExecutor()) { + var request=pool.submit(()->bounded.check(id,original,"actor",()->true));assertTrue(arrived.await(3,TimeUnit.SECONDS)); + var result=request.get(4,TimeUnit.SECONDS);assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + }finally{release.countDown();assertTrue(done.await(3,TimeUnit.SECONDS));} + } + @Test void wrongRemoteJobCannotBecomeAValidReconciliationObservation() { + f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();f.job=UUID.randomUUID().toString();f.reply(e,200,"CANCELLED");}); + var result=check();assertEquals("UNKNOWN",result.state());assertEquals(502,result.observation().httpStatus());assertNull(result.observation().status());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void sourceTamperingAfterReservationCannotReachTheRemoteService() { + var journal=spy(base.journal);var count=new AtomicInteger(); + doAnswer(i->{var target=i.callRealMethod();if(count.incrementAndGet()==1)f.mongo.getCollection(ReviewSnapshotArchive.METADATA) + .updateOne(new Document("_id",original.isolationId()),new Document("$set",new Document("expiresAt",1L)));return target; + }).when(journal).reconciliationTarget(any(),anyString(),any()); + route(200,"CANCELLED");try(var checked=new ReviewCancellationReconciler(f.mongo,journal,f.client,1,Duration.ofSeconds(5))) { + assertThrows(IllegalStateException.class,()->checked.check(id,original,"actor",()->true)); + } + assertEquals(0,reads.get());assertEquals("RESERVED",observations().find().first().getString("state")); + } + @Test void statusCanBeObservedAfterTheOriginalCancellationWindowExpires()throws Exception { + var other=new ReviewOrphanCancellationJournalTest();other.setup(); + try { + var remote=other.fixture.fixture;var journal=new ReviewOrphanCancellationJournal(remote.mongo,other.resolver,other.fixture.archive,java.time.Clock.systemUTC(),1000); + var prepared=journal.prepare(other.fixture.prepared.id(),"actor",()->true);var claim=journal.claim(prepared,"actor",()->true);assertNotNull(claim); + assertTrue(journal.beginDispatch(claim,"actor",()->true)>0);journal.record(claim,"actor",new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,503),()->true); + Thread.sleep(1100);assertTrue(System.currentTimeMillis()>=prepared.expiresAt()); + remote.route(e->{assertEquals("GET",e.getRequestMethod());remote.reply(e,200,"CANCELLED");}); + try(var later=new ReviewCancellationReconciler(remote.mongo,journal,remote.client,1,Duration.ofSeconds(5))) { + var receipt=later.check(UUID.randomUUID().toString(),prepared,"actor",()->true);assertEquals("CANCELLED",receipt.observation().status().state()); + assertEquals("UNKNOWN",journal.receipt(prepared,"actor",()->true).state());assertNull(journal.claim(prepared,"actor",()->true)); + } + }finally{other.cleanup();} + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewObservationReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewObservationReaderTest.java new file mode 100644 index 000000000..474ca311f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewObservationReaderTest.java @@ -0,0 +1,54 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewObservationReaderTest { + ReviewCancellationReconcilerTest base=new ReviewCancellationReconcilerTest(); + @BeforeEach void setup()throws Exception{base.setup();base.reconciler.initializeDiscovery();} + @AfterEach void cleanup(){base.cleanup();} + String id(int value){return String.format("00000000-0000-0000-0000-%012d",value);} + Document reference(int value,String actor,String state) { + var p=base.original; + return new Document("_id",id(value)).append("intent",new Document("actor",actor).append("isolationId",p.isolationId()).append("targetSha256",p.targetSha256()).append("createdAt",p.createdAt()).append("expiresAt",p.expiresAt())) + .append("state",state).append("privatePayload","not for discovery"); + } + ReviewObservationReader.Page page(String cursor,int limit){return base.reconciler.history(base.original,"actor",cursor,limit,()->true);} + @Test void pagesOriginalActorAndIntentWithoutRemoteCallsOrReturningPayloads() { + for(int i=1;i<=4;i++)base.observations().insertOne(reference(i,"actor","READING")); + base.observations().insertOne(reference(5,"other","OBSERVED"));var mismatch=reference(6,"actor","OBSERVED");mismatch.get("intent",Document.class).put("targetSha256","f".repeat(64));base.observations().insertOne(mismatch); + var before=base.observations().find().into(new ArrayList<>());var first=page(null,2);assertEquals(List.of(id(1),id(2)),first.items().stream().map(ReviewObservationReader.Item::id).toList()); + assertEquals("OBSERVATION_ID",first.order());assertEquals("c1."+base.original.isolationId()+"."+id(2),first.nextCursor()); + var second=page(first.nextCursor(),2);assertEquals(List.of(id(3),id(4)),second.items().stream().map(ReviewObservationReader.Item::id).toList());assertNull(second.nextCursor()); + assertEquals(before,base.observations().find().into(new ArrayList<>()));assertEquals(0,base.reads.get());assertEquals(0,base.f.identityGets.get()); + // A discovery reference is not a valid receipt: these fixtures deliberately lack its token and dates. + assertThrows(IllegalStateException.class,()->base.reconciler.receipt(id(1),base.original,"actor",()->true)); + } + @Test void rejectsCrossIsolationCursorsInvalidLimitsAndUnauthorizedActors() { + assertThrows(IllegalArgumentException.class,()->page("r1."+id(1),2));assertThrows(IllegalArgumentException.class,()->page("c1."+id(9)+"."+id(1),2)); + assertThrows(IllegalArgumentException.class,()->page(null,0));assertThrows(IllegalArgumentException.class,()->page(null,26)); + assertThrows(SecurityException.class,()->base.reconciler.history(base.original,"other",null,2,()->true)); + assertThrows(SecurityException.class,()->base.reconciler.history(base.original,"actor",null,2,()->false));assertEquals(0,base.observations().countDocuments()); + } + @Test void malformedStateIsOnlyAnUnknownReferenceAndArrayOrNumericAliasesAreExcluded() { + base.observations().insertOne(reference(1,"actor","APPROVED"));var array=reference(2,"actor","OBSERVED");array.get("intent",Document.class).put("actor",List.of("actor"));base.observations().insertOne(array); + var numeric=reference(3,"actor","OBSERVED");numeric.get("intent",Document.class).put("createdAt",(double)base.original.createdAt());base.observations().insertOne(numeric); + var invalidId=reference(4,"actor","OBSERVED");invalidId.put("_id","not-a-uuid");base.observations().insertOne(invalidId); + var result=page(null,25);assertEquals(List.of(new ReviewObservationReader.Item(id(1),"UNKNOWN")),result.items()); + } + @Test void continuationDoesNotRepeatDeletedRowsAndRefreshFindsNewEarlierRows() { + base.observations().insertMany(List.of(reference(2,"actor","READING"),reference(4,"actor","UNKNOWN"))); + var first=page(null,1);base.observations().deleteOne(new Document("_id",id(2)));base.observations().insertOne(reference(1,"actor","RESERVED")); + assertEquals(id(4),page(first.nextCursor(),1).items().getFirst().id());assertEquals(id(1),page(null,1).items().getFirst().id()); + } + @Test void losingOriginalCancellationAuthorityPreventsDiscoveryEvenWithRetainedReferences() { + base.observations().insertOne(reference(1,"actor","OBSERVED")); + base.base.operations().updateOne(new Document("_id",base.original.isolationId()),new Document("$set",new Document("intent.target.binding.jobId",id(8)))); + assertThrows(IllegalStateException.class,()->page(null,25));assertEquals(1,base.observations().countDocuments()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java new file mode 100644 index 000000000..c7e3e5f58 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java @@ -0,0 +1,112 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import com.mongodb.MongoException; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewOrphanCancellationExecutorTest { + ReviewOrphanCancellationJournalTest base=new ReviewOrphanCancellationJournalTest(); + RemoteReviewStepIntegrationTest f;ReviewOrphanCancellationJournal.Prepared prepared;ReviewOrphanCancellationExecutor executor; + AtomicInteger deletes=new AtomicInteger(); + @BeforeEach void setup()throws Exception {base.setup();f=base.fixture.fixture;prepared=base.prepare();executor=create(base.journal,Duration.ofSeconds(5));} + @AfterEach void cleanup(){executor.close();base.cleanup();} + ReviewOrphanCancellationExecutor create(ReviewOrphanCancellationJournal journal,Duration budget){return new ReviewOrphanCancellationExecutor(journal,f.client,1,budget);} + ReviewOrphanCancellationExecutor.Execution execute(){return executor.execute(prepared,"actor",()->true);} + void route(int code,String state){f.route(e->{assertEquals("DELETE",e.getRequestMethod());deletes.incrementAndGet();f.reply(e,code,state);});} + @ParameterizedTest @ValueSource(strings={"CANCELLED","COMPLETED","HELD","EXPIRED","QUEUED","RUNNING"}) + void recordsActualRemoteStateWithoutGrantingClearanceOrRepeatingDispatch(String state) { + route(200,state);var before=base.fixture.raw();var result=execute();assertEquals("OBSERVED",result.state());assertEquals("REMOTE_STATUS",result.receipt().observation().kind()); + assertEquals(state,result.receipt().observation().status().state());assertNotNull(result.receipt().observedAt()); + assertEquals(result,create(base.journal,Duration.ofSeconds(5)).execute(prepared,"actor",()->true));assertEquals(1,deletes.get());assertEquals(0,f.posts.get());assertEquals(0,f.identityGets.get()); + assertEquals(before,base.fixture.raw());base.fixture.assertIsolated();assertEquals("BLOCK",f.saved().getVerdict());verifyNoInteractions(f.storage); + } + @ParameterizedTest @ValueSource(ints={404,409,401,403,429,503}) + void rejectedAndUncertainResponsesAreDistinctAndCannotRetry(int code) { + route(code,"QUEUED");var result=execute();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==503?"UNKNOWN":"UNAVAILABLE"; + assertEquals(kind,result.receipt().observation().kind());assertEquals(code,result.receipt().observation().httpStatus());assertNull(result.receipt().observation().status()); + assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void mismatchedRemoteJobIsUnknownAndNeverCleared() { + f.route(e->{deletes.incrementAndGet();f.job=UUID.randomUUID().toString();f.reply(e,200,"CANCELLED");}); + var result=execute();assertEquals("UNKNOWN",result.state());assertNull(result.receipt().observation().status());assertEquals(502,result.receipt().observation().httpStatus());assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void droppedResponseHasOneHttpAttemptAndDurableUnknownOutcome() { + f.route(e->{deletes.incrementAndGet();e.close();});var result=execute();assertEquals("UNKNOWN",result.state());assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void remainingBudgetBoundsHttpWaitAndLeavesNoRetryAuthority()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(1); + f.route(e->{deletes.incrementAndGet();arrived.countDown();try{release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}finally{done.countDown();}}); + try(var bounded=create(base.journal,Duration.ofSeconds(1));var pool=Executors.newSingleThreadExecutor()) { + var result=pool.submit(()->bounded.execute(prepared,"actor",()->true));assertTrue(arrived.await(3,TimeUnit.SECONDS)); + var observed=result.get(4,TimeUnit.SECONDS);assertEquals("UNKNOWN",observed.state());assertEquals(observed,execute());assertEquals(1,deletes.get()); + }finally{release.countDown();assertTrue(done.await(3,TimeUnit.SECONDS));} + } + @Test void localAdmissionAndAnotherReplicaCannotDuplicateAnInFlightDispatch()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1); + f.route(e->{deletes.incrementAndGet();arrived.countDown();release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}); + try(var pool=Executors.newSingleThreadExecutor();var replica=create(base.journal,Duration.ofSeconds(5))) { + var first=pool.submit(this::execute);assertTrue(arrived.await(3,TimeUnit.SECONDS));assertEquals("BUSY",execute().state()); + assertEquals("DISPATCHING",replica.execute(prepared,"actor",()->true).state());release.countDown();assertEquals("OBSERVED",first.get(4,TimeUnit.SECONDS).state());assertEquals(1,deletes.get()); + }finally{release.countDown();} + } + @Test void lostDispatchAcknowledgementNeverRecoversIntoAnHttpRequest() { + var journal=spy(base.journal);doAnswer(i->{i.callRealMethod();throw new MongoException("Unknown dispatch acknowledgement");}).when(journal).beginDispatch(any(),anyString(),any()); + route(200,"CANCELLED");var result=create(journal,Duration.ofSeconds(5)).execute(prepared,"actor",()->true); + assertEquals("UNKNOWN",result.state());assertEquals(0,deletes.get());assertEquals(result,execute()); + } + @Test void lostObservationAcknowledgementReadsBackTheCommittedReceipt() { + var mongo=spy(f.mongo);var ops=base.intercepted(mongo); + doAnswer(i->{i.callRealMethod();throw new MongoException("Lost outcome acknowledgement");}).when(ops).updateOne(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.UpdateOptions.class)); + route(200,"CANCELLED");var result=create(base.create(mongo,Clock.systemUTC()),Duration.ofSeconds(5)).execute(prepared,"actor",()->true); + assertEquals("OBSERVED",result.state());assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void aTerminalObservationCannotBeOverwrittenByLaterBookkeeping() { + route(200,"COMPLETED");var first=execute();var record=base.operations().find().first(); + var claim=new ReviewOrphanCancellationJournal.Claim(prepared,record.getString("token"),base.resolver.resolve(prepared.isolationId(),"actor")); + var later=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",new RemoteReviewClient.Status(f.job,"CANCELLED",true,1000,2000,null),null); + assertEquals(first.receipt(),base.journal.record(claim,"actor",later,()->true));assertEquals(record,base.operations().find().first()); + } + @Test void revokedPermissionAfterRemoteReplyCannotPublishAnUnauthorizedReceiptOrRetry() { + var allowed=new AtomicBoolean(true);f.route(e->{deletes.incrementAndGet();allowed.set(false);f.reply(e,200,"CANCELLED");}); + assertThrows(SecurityException.class,()->executor.execute(prepared,"actor",allowed::get));assertEquals("DISPATCHING",execute().state());assertEquals(1,deletes.get()); + } + @Test void shutdownRefusesNewClaimsAndPermissionIsCheckedFirst() { + executor.close();assertEquals("SHUTDOWN",execute().state());assertEquals("PREPARED",base.operations().find().first().getString("state")); + assertThrows(SecurityException.class,()->executor.execute(prepared,"actor",()->false));assertEquals(0,deletes.get()); + } + @Test void forgedObservationForAnotherJobCannotBeRetained() { + var claim=base.journal.claim(prepared,"actor",()->true);assertTrue(base.journal.beginDispatch(claim,"actor",()->true)>0); + var observation=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",new RemoteReviewClient.Status(UUID.randomUUID().toString(),"CANCELLED",true,1000,2000,null),null); + assertThrows(IllegalStateException.class,()->base.journal.record(claim,"actor",observation,()->true));assertEquals("DISPATCHING",base.operations().find().first().getString("state")); + } + @Test void theSameClaimCannotCrossTheDispatchBoundaryTwice() { + var claim=base.journal.claim(prepared,"actor",()->true);assertTrue(base.journal.beginDispatch(claim,"actor",()->true)>0); + assertEquals(0,base.journal.beginDispatch(claim,"actor",()->true));assertEquals(0,deletes.get()); + var forged=new ReviewOrphanCancellationJournal.Claim(prepared,UUID.randomUUID().toString(),claim.target()); + assertThrows(IllegalStateException.class,()->base.journal.beginDispatch(forged,"actor",()->true)); + } + @Test void revocationImmediatelyAfterDispatchGatePreventsHttpSubscription() { + var allowed=new AtomicBoolean(true);var journal=spy(base.journal); + doAnswer(i->{var result=i.callRealMethod();allowed.set(false);return result;}).when(journal).beginDispatch(any(),anyString(),any()); + route(200,"CANCELLED");assertThrows(SecurityException.class,()->create(journal,Duration.ofSeconds(5)).execute(prepared,"actor",allowed::get)); + assertEquals("DISPATCHING",base.operations().find().first().getString("state"));assertEquals(0,deletes.get());assertEquals("DISPATCHING",execute().state()); + } + + @Test void deadlineBeforeArmingIsOperationalStateRatherThanPermissionDenial() { + var journal=spy(base.journal);doAnswer(i->{Thread.sleep(1100);return i.callRealMethod();}).when(journal).claim(any(),anyString(),any()); + route(200,"CANCELLED");var result=create(journal,Duration.ofSeconds(1)).execute(prepared,"actor",()->true); + assertEquals("PREPARED",result.state());assertEquals(0,deletes.get());assertFalse(base.operations().find().first().containsKey("token")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java new file mode 100644 index 000000000..d26551421 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java @@ -0,0 +1,173 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewOrphanCancellationJournalTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewOrphanTargetResolver resolver;ReviewOrphanCancellationJournal journal; + @BeforeEach void setup()throws Exception { + fixture.setup();fixture.fixture.attached(30000);fixture.fixture.change("scanResult.verdict","BLOCK");fixture.isolateExpiredBrokenPoll(); + resolver=new ReviewOrphanTargetResolver(fixture.fixture.mongo,fixture.archive,fixture.isolation);journal=create(fixture.fixture.mongo,Clock.systemUTC()); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewOrphanCancellationJournal create(MongoTemplate mongo,Clock clock){return new ReviewOrphanCancellationJournal(mongo,resolver,fixture.archive,clock,60000);} + ReviewOrphanCancellationJournal.Prepared prepare(){return journal.prepare(fixture.prepared.id(),"actor",()->true);} + MongoCollection operations(){return fixture.fixture.mongo.getCollection(ReviewOrphanCancellationJournal.COLLECTION);} + @Test void preparationRetainsOriginalTargetAndExpiryAcrossRestartWithoutRemoteCalls() { + var before=fixture.raw();var prepared=prepare();var record=operations().find().first(); + assertEquals(prepared,create(fixture.fixture.mongo,Clock.systemUTC()).prepare(fixture.prepared.id(),"actor",()->true));assertEquals(record,operations().find().first()); + assertEquals(fixture.prepared.id(),prepared.isolationId());assertTrue(prepared.targetSha256().matches("[0-9a-f]{64}")); + fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString());assertEquals(prepared,prepare()); + assertEquals("BLOCK",fixture.fixture.saved().getVerdict());assertEquals(before.get("reviewStatus"),fixture.raw().get("reviewStatus")); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @Test void previewUsesAuthenticatedOriginalTargetAndMatchesPreparedDigestWithoutCreatingWork() { + var before=fixture.raw();long archives=fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments(); + var preview=journal.preview(fixture.prepared.id(),"actor",()->true); + assertEquals(fixture.prepared.id(),preview.isolationId());assertEquals(fixture.prepared.sha256(),preview.beforeSha256()); + assertEquals(fixture.fixture.job,preview.jobId());assertEquals(0,preview.versionIndex()); + assertEquals(0,operations().countDocuments());assertEquals(archives,fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(before,fixture.raw()); + fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + assertEquals(preview,journal.preview(fixture.prepared.id(),"actor",()->true)); + fixture.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(preview,journal.preview(fixture.prepared.id(),"actor",()->true)); + assertEquals(preview.targetSha256(),prepare().targetSha256()); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @Test void previewRejectsUnconfirmedIsolationRevokedPermissionAndOtherActors() { + assertThrows(SecurityException.class,()->journal.preview(fixture.prepared.id(),"other",()->true)); + assertThrows(SecurityException.class,()->journal.preview("invalid","actor",()->false)); + var calls=new java.util.concurrent.atomic.AtomicInteger();assertThrows(SecurityException.class,()->journal.preview(fixture.prepared.id(),"actor",()->calls.incrementAndGet()<3)); + fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->journal.preview(fixture.prepared.id(),"actor",()->true));assertEquals(0,operations().countDocuments()); + } + @Test void expiredIntentRecoveryIsReadOnlyAndNeverRenewsCancellationAuthority() { + var old=create(fixture.fixture.mongo,Clock.fixed(Instant.now().minusSeconds(3600),ZoneOffset.UTC)); + var prepared=old.prepare(fixture.prepared.id(),"actor",()->true);var before=operations().find().first();var version=fixture.raw(); + var recovered=create(fixture.fixture.mongo,Clock.systemUTC()).recover(prepared.isolationId(),"actor",()->true); + assertEquals(prepared,recovered.prepared());assertEquals("PREPARED",recovered.state());assertNull(recovered.observation()); + assertThrows(IllegalStateException.class,()->prepare());assertNull(journal.claim(recovered.prepared(),"actor",()->true)); + assertEquals(before,operations().find().first());assertEquals(version,fixture.raw()); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @Test void unknownOutcomeRecoveryAfterRestartRetainsOriginalObservationAndNoToken() { + var prepared=prepare();var claim=journal.claim(prepared,"actor",()->true);assertTrue(journal.beginDispatch(claim,"actor",()->true)>0); + var receipt=journal.record(claim,"actor",new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,503),()->true); + var before=operations().find().first(); + assertEquals(receipt,create(fixture.fixture.mongo,Clock.systemUTC()).recover(prepared.isolationId(),"actor",()->true)); + assertEquals(before,operations().find().first());assertNull(journal.claim(prepared,"actor",()->true)); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + @Test void missingCancellationRecoveryDoesNotPrepareOrCreateAnArchive() { + var before=fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments(); + assertThrows(IllegalStateException.class,()->journal.recover(fixture.prepared.id(),"actor",()->true)); + assertEquals(0,operations().countDocuments());assertEquals(before,fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void recoveryRequiresOriginalActorAndCurrentPermissionBeforeReturningEvidence() { + var prepared=prepare();var before=operations().find().first(); + assertThrows(SecurityException.class,()->journal.recover(prepared.isolationId(),"other",()->true)); + assertThrows(SecurityException.class,()->journal.recover("invalid","actor",()->false)); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + assertThrows(SecurityException.class,()->journal.recover(prepared.isolationId(),"actor",()->calls.incrementAndGet()<3)); + assertEquals(before,operations().find().first()); + } + @Test void concurrentCallersCanObtainOnlyOneExecutionToken()throws Exception { + var prepared=prepare();var start=new CountDownLatch(1); + try(var pool=Executors.newFixedThreadPool(2)) { + var one=pool.submit(()->{start.await();return journal.claim(prepared,"actor",()->true);}); + var two=pool.submit(()->{start.await();return create(fixture.fixture.mongo,Clock.systemUTC()).claim(prepared,"actor",()->true);});start.countDown(); + var a=one.get(10,TimeUnit.SECONDS);var b=two.get(10,TimeUnit.SECONDS);assertNotEquals(a==null,b==null); + var winner=a==null?b:a;assertEquals(fixture.fixture.job,winner.target().binding().jobId());assertNotNull(winner.target().binding().origin()); + } + assertNull(journal.claim(prepared,"actor",()->true));assertEquals(1,operations().countDocuments());assertEquals("EXECUTING",operations().find().first().getString("state")); + } + @Test void databaseTimeRejectsExpiredOrFutureIntentEvenWithSkewedClientClock() { + var old=create(fixture.fixture.mongo,Clock.fixed(Instant.now().minusSeconds(3600),ZoneOffset.UTC));var prepared=old.prepare(fixture.prepared.id(),"actor",()->true); + assertNull(old.claim(prepared,"actor",()->true));assertEquals("PREPARED",operations().find().first().getString("state")); + assertThrows(IllegalStateException.class,()->prepare()); + } + @Test void futureIntentCannotExecute() { + var future=create(fixture.fixture.mongo,Clock.fixed(Instant.now().plusSeconds(3600),ZoneOffset.UTC));var prepared=future.prepare(fixture.prepared.id(),"actor",()->true); + assertNull(future.claim(prepared,"actor",()->true)); + } + @ParameterizedTest @ValueSource(strings={"actor","target","expiry","extra","state","numericType"}) + void alteredStoredIntentOrStateCannotAcquireAuthority(String field) { + var prepared=prepare();var change=switch(field) { + case "actor"->new Document("intent.actor","other");case "target"->new Document("intent.target.binding.origin.deploymentId","22222222-2222-2222-2222-222222222222"); + case "expiry"->new Document("intent.expiresAt",prepared.expiresAt()+1);case "extra"->new Document("extra",true);case "numericType"->new Document("intent.target.binding.attempt",1L); + default->new Document("state",List.of("PREPARED"));}; + operations().updateOne(new Document("_id",prepared.isolationId()),new Document("$set",change));var before=operations().find().first(); + assertThrows(IllegalStateException.class,()->journal.claim(prepared,"actor",()->true));assertThrows(IllegalStateException.class,()->journal.recover(prepared.isolationId(),"actor",()->true));assertEquals(before,operations().find().first()); + } + @Test void permissionAndArchiveAuthorityAreRequiredBeforeCreatingIntent() { + assertThrows(SecurityException.class,()->journal.prepare(fixture.prepared.id(),"actor",()->false));assertThrows(SecurityException.class,()->journal.prepare(fixture.prepared.id(),"other",()->true)); + fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->prepare());assertEquals(0,operations().countDocuments()); + } + MongoCollection intercepted(MongoTemplate template) { + var configured=operations().withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + var spy=spy(configured);doReturn(spy).when(template).getCollection(ReviewOrphanCancellationJournal.COLLECTION); + doReturn(spy).when(spy).withReadPreference(any());doReturn(spy).when(spy).withReadConcern(any());doReturn(spy).when(spy).withWriteConcern(any());doReturn(spy).when(spy).withTimeout(anyLong(),any());return spy; + } + @Test void lostPreparationAcknowledgementRetainsOneOriginalIntent() { + var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost preparation acknowledgement");}).when(ops).insertOne(any(Document.class)); + var prepared=create(mongo,Clock.systemUTC()).prepare(fixture.prepared.id(),"actor",()->true);assertEquals(prepared,prepare());assertEquals(1,operations().countDocuments()); + } + @Test void lostClaimAcknowledgementRecoversOnlyThisInvocationsToken() { + var prepared=prepare();var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doAnswer(i->{i.callRealMethod();throw new MongoException("Lost claim acknowledgement");}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + var claim=create(mongo,Clock.systemUTC()).claim(prepared,"actor",()->true);assertNotNull(claim);assertEquals(claim.token(),operations().find().first().getString("token"));assertNull(journal.claim(prepared,"actor",()->true)); + } + @Test void revokedPermissionAfterArmingCannotExposeOrReclaimToken() { + var prepared=prepare();var permitted=new AtomicBoolean(true);var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doAnswer(i->{var result=i.callRealMethod();permitted.set(false);return result;}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + assertThrows(SecurityException.class,()->create(mongo,Clock.systemUTC()).claim(prepared,"actor",permitted::get));assertEquals("EXECUTING",operations().find().first().getString("state"));assertNull(journal.claim(prepared,"actor",()->true)); + } + @Test void anUncommittedFailedClaimNeverReturnsExecutionAuthority() { + var prepared=prepare();var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doThrow(new MongoException("Claim unavailable")).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + assertThrows(IllegalStateException.class,()->create(mongo,Clock.systemUTC()).claim(prepared,"actor",()->true));assertEquals("PREPARED",operations().find().first().getString("state")); + } + @ParameterizedTest @ValueSource(strings={"attempt","versionIndex","state"}) + void aTypeSubstitutionAfterValidationCannotWinTheClaim(String field) { + var prepared=prepare();var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doAnswer(i->{ + var patch=switch(field){case "attempt"->new Document("intent.target.binding.attempt",1L);case "versionIndex"->new Document("intent.target.versionIndex",0L);default->new Document("state",List.of("PREPARED"));}; + operations().updateOne(new Document("_id",prepared.isolationId()),new Document("$set",patch));return i.callRealMethod(); + }).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + assertNull(create(mongo,Clock.systemUTC()).claim(prepared,"actor",()->true));assertFalse(operations().find().first().containsKey("token")); + } + + @Test void alteredJournalTimesCannotRenewPreparationAgainstTheSignedArchive() { + var prepared=prepare();operations().updateOne(new Document("_id",prepared.isolationId()),new Document("$set",new Document("intent.createdAt",prepared.createdAt()+1).append("intent.expiresAt",prepared.expiresAt()+1))); + assertThrows(IllegalStateException.class,()->prepare()); + var forged=new ReviewOrphanCancellationJournal.Prepared(prepared.isolationId(),prepared.targetSha256(),prepared.createdAt()+1,prepared.expiresAt()+1); + assertThrows(IllegalStateException.class,()->journal.claim(forged,"actor",()->true));assertFalse(operations().find().first().containsKey("token")); + } + @Test void aTamperedCancellationArchiveCannotAuthorizeAClaim() { + var prepared=prepare();fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("action","CANCEL_REMOTE_REVIEW"),new Document("$set",new Document("expiresAt",prepared.expiresAt()+1))); + assertThrows(IllegalStateException.class,()->journal.claim(prepared,"actor",()->true));assertFalse(operations().find().first().containsKey("token")); + } + @Test void archivedIntentSurvivesJournalInsertFailureWithoutRenewingExpiry() { + var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo);doThrow(new MongoException("No journal acknowledgement")).when(ops).insertOne(any(Document.class)); + assertThrows(IllegalStateException.class,()->create(mongo,Clock.systemUTC()).prepare(fixture.prepared.id(),"actor",()->true)); + var retained=fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).find(new Document("action","CANCEL_REMOTE_REVIEW")).first();assertNotNull(retained); + var prepared=prepare();assertEquals(retained.getLong("createdAt"),prepared.createdAt());assertEquals(retained.getLong("expiresAt"),prepared.expiresAt()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java new file mode 100644 index 000000000..eee43e01d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewOrphanTargetResolverTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewOrphanTargetResolver resolver; + @BeforeEach void setup()throws Exception { + fixture.setup();resolver=new ReviewOrphanTargetResolver(fixture.fixture.mongo,fixture.archive,fixture.isolation); + } + @AfterEach void cleanup(){fixture.cleanup();} + @Test void usesOriginalAuthenticatedBindingEvenAfterCurrentVersionIsEditedOrDeleted() { + var claim=fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll();var before=fixture.raw(); + var target=resolver.resolve(fixture.prepared.id(),"actor");assertEquals(claim.binding(),target.binding()); + assertEquals(before.get("_id"),target.projectId());assertEquals(0,target.versionIndex());assertEquals(fixture.prepared.sha256(),target.beforeSha256()); + assertEquals(before,fixture.raw());fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + assertEquals(target,resolver.resolve(fixture.prepared.id(),"actor"));fixture.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(target,resolver.resolve(fixture.prepared.id(),"actor"));assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @ParameterizedTest @ValueSource(strings={"projectId","versionId","requestId","attempt","filePath","artifactSha256","contextSha256","manualRescan"}) + void refusesAMismatchedBindingThatIsolationCorrectlyPreserved(String field) { + fixture.fixture.attached(30000); + Object value=switch(field){case "attempt"->2;case "manualRescan"->true;case "requestId"->UUID.randomUUID().toString();case "artifactSha256","contextSha256"->"d".repeat(64);default->"different";}; + fixture.fixture.change("scanResult.remoteReview."+field,value);fixture.isolateExpiredBrokenPoll();var before=fixture.raw(); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor"));assertEquals(before,fixture.raw()); + } + @Test void refusesMissingJobWithoutRemoteDiscovery() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remoteReview.jobId",null);fixture.isolateExpiredBrokenPoll(); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor"));assertEquals(0,fixture.fixture.gets.get()); + } + @Test void anotherActorOrAnUnconfirmedIsolationCannotSupplyACancellationTarget() { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll();assertThrows(SecurityException.class,()->resolver.resolve(fixture.prepared.id(),"other")); + fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + @Test void tamperedArchiveCannotSupplyATarget() { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll(); + fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("actorId","other"))); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + @Test void malformedArchivedBindingIsNotReconstructedFromTheCurrentVersion() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remoteReview",new Document("jobId","invalid"));fixture.isolateExpiredBrokenPoll(); + assertNull(fixture.fixture.saved().getRemoteReview());assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + @Test void brokenPollPayloadDoesNotPreventResolvingTheIntactOriginalBinding() { + var claim=fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remotePoll.token",17);fixture.isolateExpiredBrokenPoll(); + assertEquals(claim.binding(),resolver.resolve(fixture.prepared.id(),"actor").binding()); + } + + @ParameterizedTest @ValueSource(strings={"position","bytes"}) + void archiveReadMustMatchTheSameReceiptEvenIfStorageChangesBetweenReads(String field) { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll();var source=fixture.archive.load(fixture.prepared.id());var archive=spy(fixture.archive); + byte[] bytes=source.versionBytes();if(field.equals("bytes")){var doc=new org.bson.RawBsonDocument(bytes).decode(new org.bson.codecs.DocumentCodec());doc.put("manifestVersion","different");var buffer=new org.bson.RawBsonDocument(doc,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();bytes=new byte[buffer.remaining()];buffer.get(bytes);} + var changed=new ReviewSnapshotArchive.Snapshot(source.id(),source.projectId(),field.equals("position")?1:source.versionIndex(),source.actorId(),source.action(),source.createdAt(),source.expiresAt(),bytes); + doReturn(changed).when(archive).load(source.id()); + var resolver=new ReviewOrphanTargetResolver(fixture.fixture.mongo,archive,fixture.isolation); + assertThrows(IllegalStateException.class,()->resolver.resolve(source.id(),"actor")); + } + + @Test void legacyBindingWithoutOriginCannotBecomeACancellationTarget() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remoteReview.origin",null);fixture.isolateExpiredBrokenPoll(); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewRemoteTargetReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewRemoteTargetReaderTest.java new file mode 100644 index 000000000..932c9809c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewRemoteTargetReaderTest.java @@ -0,0 +1,64 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRemoteTargetReaderTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewRemoteTargetReader reader; + @BeforeEach void setup()throws Exception { + fixture.setup();reader=new ReviewRemoteTargetReader(fixture.fixture.mongo); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewRemoteTargetReader.Captured capture(){return reader.capture(fixture.raw().get("_id"),0,"v");} + + @ParameterizedTest @ValueSource(strings={"running","completed","failed","isolated"}) + void capturesExactHistoryWithoutChangingSecurityStateOrContactingRemote(String state) { + var claim=fixture.fixture.attached(30000); + fixture.fixture.change("scanResult.verdict","BLOCK"); + switch(state) { + case "completed" -> {fixture.fixture.change("scanResult.status","INFECTED");fixture.fixture.change("scanResult.scanState","COMPLETED");} + case "failed" -> {fixture.fixture.change("scanResult.status","FAILED");fixture.fixture.change("scanResult.scanState","REMOTE_UNAVAILABLE");} + case "isolated" -> fixture.isolateExpiredBrokenPoll(); + } + var before=fixture.raw();var captured=capture(); + assertEquals(claim.binding(),captured.binding()); + assertArrayEquals(fixture.reader.capture(before.get("_id"),0,"v").versionBytes(),captured.snapshot().versionBytes()); + assertEquals(before,fixture.raw());assertEquals("BLOCK",fixture.fixture.saved().getVerdict()); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + var bytes=captured.snapshot().versionBytes();bytes[0]=0; + assertNotEquals(0,captured.snapshot().versionBytes()[0]); + } + + @ParameterizedTest @ValueSource(strings={"floatAttempt","overflowAttempt","stringAttempt","missingOrigin","missingJob","stringManual","changedHash","changedContext"}) + void malformedOrStaleTargetsCannotBecomeReplacementEvidence(String corruption) { + fixture.fixture.attached(30000); + switch(corruption) { + case "floatAttempt" -> fixture.fixture.change("scanResult.remoteReview.attempt",1.0); + case "overflowAttempt" -> fixture.fixture.change("scanResult.remoteReview.attempt",4294967297L); + case "stringAttempt" -> fixture.fixture.change("scanResult.remoteReview.attempt","1"); + case "missingOrigin" -> fixture.fixture.change("scanResult.remoteReview.origin",null); + case "missingJob" -> fixture.fixture.change("scanResult.remoteReview.jobId",null); + case "stringManual" -> fixture.fixture.change("scanResult.remoteReview.manualRescan","false"); + case "changedHash" -> fixture.fixture.change("hash","d".repeat(64)); + case "changedContext" -> fixture.fixture.change("gameVersions",java.util.List.of("changed")); + } + var before=fixture.raw();assertThrows(IllegalStateException.class,this::capture); + assertEquals(before,fixture.raw());assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + + @Test void stalePositionOrDuplicateVersionIdentityCannotBeCaptured() { + fixture.fixture.attached(30000);var root=fixture.raw(); + assertThrows(IllegalStateException.class,()->reader.capture(root.get("_id"),1,"v")); + var version=root.getList("versions",Document.class).getFirst(); + fixture.fixture.mongo.getCollection("projects").updateOne(new Document("_id",root.get("_id")), + new Document("$push",new Document("versions",version))); + assertThrows(IllegalStateException.class,this::capture); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementActivationDecisionTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementActivationDecisionTest.java new file mode 100644 index 000000000..ac37e77ab --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementActivationDecisionTest.java @@ -0,0 +1,110 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementActivationDecisionTest { + ReviewReplacementJobAccountingTest base=new ReviewReplacementJobAccountingTest(); + Clock clock=Clock.fixed(Instant.now(),ZoneOffset.UTC); + String id=UUID.randomUUID().toString();ReviewReplacementActivationDecision decisions; + @BeforeEach void setup()throws Exception {base.setup();decisions=create(base.fixture.fixture.archive,clock);} + @AfterEach void cleanup(){base.cleanup();} + ReviewReplacementActivationDecision create(ReviewSnapshotArchive archive,Clock clock) { + var f=base.fixture;return new ReviewReplacementActivationDecision(f.fixture.fixture.mongo,archive,f.preparation,base.executor,base.accounting,f.fixture.reader,clock,60000); + } + void observe(int code,String state,String workState) { + var f=base.fixture.fixture.fixture;f.route(exchange->f.reply(exchange,code,state,workState));base.check(); + } + ReviewReplacementActivationDecision.Prepared prepare(boolean acknowledge) { + return decisions.prepare(id,base.prepared,base.id,"next-moderator",acknowledge,()->true); + } + + @Test void completedOriginalNeedsNoAdditionalAcknowledgementAndAllowsADifferentActingModerator() { + observe(200,"COMPLETED","COMPLETED");var before=base.fixture.fixture.raw();var prepared=prepare(false); + assertEquals("ORIGINAL_COMPLETED",prepared.rule());assertFalse(prepared.acknowledgedUncertainty()); + assertEquals("next-moderator",base.fixture.fixture.archive.load(id).actorId());assertEquals(prepared,prepare(false)); + assertEquals(prepared,create(base.fixture.fixture.archive,Clock.offset(clock,Duration.ofSeconds(10))).recover(id,"next-moderator",()->true)); + assertEquals(before,base.fixture.fixture.raw());assertEquals("HELD",base.fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).find().first().get("state")); + assertEquals(1,base.fixture.fixture.fixture.gets.get());assertEquals(0,base.fixture.fixture.fixture.posts.get()); + } + + @ParameterizedTest @ValueSource(strings={"RUNNING","CANCELLED","EXPIRED","QUEUED","HELD","COMPLETED"}) + void uncertaintyRequiresAnExplicitRetainedAcknowledgement(String state) { + observe(200,state,null);var before=base.fixture.fixture.raw(); + assertThrows(IllegalStateException.class,()->prepare(false));assertNull(base.fixture.fixture.archive.find(id)); + var prepared=prepare(true);assertEquals("ACKNOWLEDGED_UNCERTAINTY",prepared.rule());assertTrue(prepared.acknowledgedUncertainty()); + assertEquals(before,base.fixture.fixture.raw());assertEquals(1,base.fixture.fixture.fixture.gets.get()); + } + + @ParameterizedTest @ValueSource(ints={404,409,403,503}) + void failedRemoteObservationsCannotSilentlyAuthorizeDuplicateWork(int status) { + observe(status,"QUEUED",null);assertThrows(IllegalStateException.class,()->prepare(false)); + assertEquals("ACKNOWLEDGED_UNCERTAINTY",prepare(true).rule()); + } + + @Test void retriesCannotExtendExpiryOrRewriteAcknowledgement() { + observe(200,"COMPLETED","COMPLETED");var prepared=prepare(false); + assertThrows(IllegalStateException.class,()->prepare(true)); + var later=create(base.fixture.fixture.archive,Clock.offset(clock,Duration.ofSeconds(60))); + assertThrows(IllegalStateException.class,()->later.prepare(id,base.prepared,base.id,"next-moderator",false,()->true)); + assertEquals(prepared,later.recover(id,"next-moderator",()->true)); + } + + @ParameterizedTest @ValueSource(strings={"version","admission","actor"}) + void changedStateOrActorCannotBeSubstitutedOnRetry(String changed) { + observe(200,"COMPLETED","COMPLETED");var prepared=prepare(false);var f=base.fixture.fixture.fixture; + switch(changed) { + case "version" -> f.change("changelog","concurrent metadata edit"); + case "admission" -> f.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).updateOne(new Document("_id",base.prepared.id()),new Document("$set",new Document("state","READY"))); + case "actor" -> {assertThrows(IllegalStateException.class,()->decisions.prepare(id,base.prepared,base.id,"other",false,()->true));return;} + } + assertThrows(IllegalStateException.class,()->prepare(false));assertEquals(prepared,decisions.recover(id,"next-moderator",()->true)); + } + + @Test void lostArchiveReplyRemainsRecoverableWithoutAnotherObservation() { + observe(200,"COMPLETED","COMPLETED");var archive=spy(base.fixture.fixture.archive); + doAnswer(call->{call.callRealMethod();throw new IllegalStateException("lost reply");}).when(archive).retain(any()); + assertThrows(IllegalStateException.class,()->create(archive,clock).prepare(id,base.prepared,base.id,"next-moderator",false,()->true)); + assertEquals(decisions.recover(id,"next-moderator",()->true),prepare(false));assertEquals(1,base.fixture.fixture.fixture.gets.get()); + } + + @Test void permissionRevocationDuringRetentionPreventsReturningADecision() { + observe(200,"COMPLETED","COMPLETED");var archive=spy(base.fixture.fixture.archive);var allowed=new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call->{var retained=call.callRealMethod();allowed.set(false);return retained;}).when(archive).retain(any()); + assertThrows(SecurityException.class,()->create(archive,clock).prepare(id,base.prepared,base.id,"next-moderator",false,allowed::get)); + assertEquals("HELD",base.fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).find().first().get("state")); + } + + @Test void signedPayloadCannotClaimCompletionForAnUncertainObservation() { + observe(200,"CANCELLED","CANCELLED");prepare(true);var source=base.fixture.fixture.archive.load(id); + var body=new org.bson.RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + body.put("rule","ORIGINAL_COMPLETED");body.put("acknowledgedUncertainty",false); + var buffer=new org.bson.RawBsonDocument(body,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes); + String other=UUID.randomUUID().toString(); + base.fixture.fixture.archive.retain(new ReviewSnapshotArchive.Snapshot(other,source.projectId(),source.versionIndex(),source.actorId(),source.action(),source.createdAt(),source.expiresAt(),bytes)); + assertThrows(IllegalStateException.class,()->decisions.recover(other,"next-moderator",()->true)); + } + + @Test void observationMutationAfterSigningCannotRewriteTheSameDecision() { + observe(200,"COMPLETED","COMPLETED");var prepared=prepare(false); + base.fixture.fixture.fixture.mongo.getCollection(ReviewReplacementJobAccounting.COLLECTION).updateOne(new Document("_id",base.id), + new Document("$set",new Document("observation.status.workState","RUNNING"))); + assertThrows(IllegalStateException.class,()->prepare(false));assertThrows(IllegalStateException.class,()->prepare(true)); + assertEquals(prepared,decisions.recover(id,"next-moderator",()->true)); + } + @Test void unfinishedObservationCannotBeUsedForActivationEvenWithAcknowledgement() { + observe(200,"COMPLETED","COMPLETED"); + base.fixture.fixture.fixture.mongo.getCollection(ReviewReplacementJobAccounting.COLLECTION).updateOne(new Document("_id",base.id), + new Document("$set",new Document("state","READING")).append("$unset",new Document("observation","").append("receivedAt",""))); + assertThrows(IllegalStateException.class,()->prepare(true));assertNull(base.fixture.fixture.archive.find(id)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementActivatorTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementActivatorTest.java new file mode 100644 index 000000000..cb124fcb5 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementActivatorTest.java @@ -0,0 +1,114 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.ClientSession; +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementActivatorTest { + ReviewReplacementActivationDecisionTest base=new ReviewReplacementActivationDecisionTest(); + ReviewReplacementActivationDecision.Prepared decision;ReviewRepairJournal journal; + @BeforeEach void setup()throws Exception { + base.setup();base.observe(200,"COMPLETED","COMPLETED");decision=base.prepare(false); + journal=new ReviewRepairJournal(base.base.fixture.fixture.fixture.mongo,base.base.fixture.fixture.archive); + } + @AfterEach void cleanup(){base.cleanup();} + ReviewReplacementActivator activator() { + var f=base.base.fixture; + return new ReviewReplacementActivator(f.fixture.fixture.mongo,base.decisions,f.fixture.archive,journal,f.fixture.reader,()->f.configuration); + } + Document admission(){return base.base.fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).find().first();} + + @Test void activationBecomesDurablyDiscoverableWithoutAnInMemoryEnqueue() { + var f=base.base.fixture.fixture.fixture;var result=activator().activate(decision,"next-moderator",()->true); + assertEquals("APPLIED",result.state());assertEquals("ACTIVE",admission().get("state"));assertEquals(decision.id(),admission().get("activationId")); + assertEquals(base.base.prepared.replacement(),f.saved().getRemoteReview());assertEquals("REMOTE_REVIEW",f.saved().getScanState()); + var discovered=new RemoteReviewDiscovery(f.mongo).page(null,64).candidates();assertEquals(1,discovered.size()); + assertEquals(base.base.prepared.replacement().requestId(),discovered.getFirst().requestId()); + assertEquals(result,activator().activate(decision,"next-moderator",()->true));assertEquals(1,f.gets.get());assertEquals(0,f.posts.get()); + assertNotNull(new RemoteReviewPollStore(f.mongo).claim(base.base.prepared.replacement(),30000)); + } + + @ParameterizedTest @ValueSource(strings={"DRAFT","ARCHIVED","DELETED"}) + void withdrawnLifecycleCannotActivate(String status) { + var f=base.base.fixture.fixture.fixture;f.mongo.getCollection("projects").updateOne(new Document("_id",base.base.fixture.fixture.raw().get("_id")),new Document("$set",new Document("status",status))); + assertEquals("NOT_APPLIED",activator().activate(decision,"next-moderator",()->true).state());assertEquals("HELD",admission().get("state")); + assertTrue(new RemoteReviewDiscovery(f.mongo).page(null,64).candidates().isEmpty()); + } + + @Test void changedConfigurationCannotActivateOrReserveAnExecution() { + var f=base.base.fixture; + var changed=new ReviewReplacementActivator(f.fixture.fixture.mongo,base.decisions,f.fixture.archive,journal,f.fixture.reader,()->null); + assertThrows(IllegalStateException.class,()->changed.activate(decision,"next-moderator",()->true));assertEquals("HELD",admission().get("state")); + assertNull(f.fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).find(new Document("_id",decision.id())).first()); + } + + @Test void revocationAfterVersionWriteRollsBackActivation() { + var f=base.base.fixture;var reader=spy(f.fixture.reader);var allowed=new AtomicBoolean(true);var before=f.fixture.raw(); + doAnswer(call->{var captured=(RawReviewSnapshotReader.Captured)call.callRealMethod();if(!decision.heldSha256().equals(captured.sha256()))allowed.set(false);return captured;}) + .when(reader).capture(any(ClientSession.class),eq(before.get("_id")),eq(0),eq("v")); + var activator=new ReviewReplacementActivator(f.fixture.fixture.mongo,base.decisions,f.fixture.archive,journal,reader,()->f.configuration); + assertThrows(SecurityException.class,()->activator.activate(decision,"next-moderator",allowed::get)); + assertEquals(before,f.fixture.raw());assertEquals("HELD",admission().get("state")); + } + + @Test void configurationChangeAfterVersionWriteRollsBackActivation() { + var f=base.base.fixture;var reader=spy(f.fixture.reader);var unchanged=new AtomicBoolean(true);var before=f.fixture.raw(); + doAnswer(call->{var captured=(RawReviewSnapshotReader.Captured)call.callRealMethod();if(!decision.heldSha256().equals(captured.sha256()))unchanged.set(false);return captured;}) + .when(reader).capture(any(ClientSession.class),eq(before.get("_id")),eq(0),eq("v")); + var activator=new ReviewReplacementActivator(f.fixture.fixture.mongo,base.decisions,f.fixture.archive,journal,reader,()->unchanged.get()?f.configuration:null); + assertEquals("UNKNOWN",activator.activate(decision,"next-moderator",()->true).state()); + assertEquals(before,f.fixture.raw());assertEquals("HELD",admission().get("state")); + assertTrue(new RemoteReviewDiscovery(f.fixture.fixture.mongo).page(null,64).candidates().isEmpty()); + } + + @Test void changedHeldEvidenceCannotConsumeTheSignedDecision() { + var f=base.base.fixture.fixture.fixture;f.change("replacementSecurityHold",java.util.UUID.randomUUID().toString()); + assertThrows(IllegalStateException.class,()->activator().activate(decision,"next-moderator",()->true)); + assertEquals("HELD",admission().get("state"));assertEquals("REPLACEMENT_HELD",f.saved().getScanState()); + assertNull(f.mongo.getCollection(ReviewRepairJournal.COLLECTION).find(new Document("_id",decision.id())).first()); + } + + @Test void lostCommitReplyRecoversWithoutRepeatingActivation() { + var f=base.base.fixture;var mongo=spy(f.fixture.fixture.mongo);var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(mongo).getMongoDatabaseFactory(); + doAnswer(call->{var session=spy((ClientSession)call.callRealMethod());doAnswer(commit->{commit.callRealMethod();throw new MongoException("lost commit reply");}).when(session).commitTransaction();return session;}) + .when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ReviewReplacementActivator(mongo,base.decisions,f.fixture.archive,journal,f.fixture.reader,()->f.configuration).activate(decision,"next-moderator",()->true); + assertEquals("APPLIED",result.state());assertEquals(result,activator().activate(decision,"next-moderator",()->true)); + assertEquals(1,f.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).countDocuments()); + } + + @Test void activatedRequestIsDeliveredOnceAndCompletedByTheExistingWorker() { + var f=base.base.fixture.fixture.fixture; + assertEquals("APPLIED",activator().activate(decision,"next-moderator",()->true).state()); + f.server.removeContext("/api/v1/review-jobs");f.binding=base.base.prepared.replacement();f.origin=f.binding.origin();f.job=java.util.UUID.randomUUID().toString(); + when(f.policy.currentPolicyVersion()).thenReturn(f.binding.policyVersion());var created=new AtomicBoolean(); + f.route(exchange->{if(exchange.getRequestMethod().equals("POST")){created.set(true);f.reply(exchange,202,"QUEUED");} + else f.reply(exchange,created.get()?200:404,"COMPLETED");}); + assertEquals("RECORDED",f.step.advance(f.binding).state());assertEquals(1,f.posts.get()); + f.ready();var restarted=new RemoteReviewStep(new RemoteReviewPollStore(f.mongo),f.client,f.storage,f.completion); + assertEquals("APPLIED",restarted.advance(f.saved().getRemoteReview()).state()); + var approved=f.mongo.findById(f.project,net.modtale.model.project.Project.class).getVersions().getFirst(); + assertEquals(net.modtale.model.project.ProjectVersion.ReviewStatus.APPROVED,approved.getReviewStatus()); + assertNotNull(approved.getApprovedSecurityEvidence());assertEquals(f.binding.artifactSha256(),approved.getApprovedSecurityEvidence().artifactSha256()); + assertEquals(f.binding.requestId(),approved.getReviewReplacement().requestId());assertEquals(1,f.posts.get()); + verify(f.storage,times(1)).downloadBounded(anyString(),anyInt()); + assertNull(approved.getScanResult());assertEquals(f.binding.withJobId(f.job),approved.getRetainedRemoteReview()); + var preparation=base.base.fixture; + assertEquals(approved.getRetainedRemoteReview(),preparation.targets.capture(preparation.fixture.raw().get("_id"),0,"v").binding()); + var next=preparation.preparation.prepare(preparation.request(),()->true); + assertEquals(f.binding.attempt()+1,next.replacement().attempt()); + assertEquals("APPLIED",base.base.executor.stage(next,"new-moderator",()->true).state()); + assertEquals(next.replacement(),f.saved().getRemoteReview()); + assertNull(f.mongo.findById(f.project,net.modtale.model.project.Project.class).getVersions().getFirst().getRetainedRemoteReview()); + + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementAdmissionReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementAdmissionReaderTest.java new file mode 100644 index 000000000..30032f270 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementAdmissionReaderTest.java @@ -0,0 +1,69 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementAdmissionReaderTest { + ReviewReplacementPreparationTest fixture=new ReviewReplacementPreparationTest(); + ReviewReplacementAdmissionReader reader; + @BeforeEach void setup()throws Exception {fixture.setup();reader=new ReviewReplacementAdmissionReader(fixture.fixture.fixture.mongo);} + @AfterEach void cleanup(){fixture.cleanup();} + com.mongodb.client.MongoCollection collection(){return fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS);} + String id(int n){return "00000000-0000-0000-0000-"+String.format("%012d",n);} + + @Test void discoversStagedWorkAfterReaderReconstructionWithoutChangingOrDispatchingIt() { + var prepared=fixture.preparation.prepare(fixture.request(),()->true); + var executor=new ReviewReplacementExecutor(fixture.fixture.fixture.mongo,fixture.preparation,fixture.fixture.reader, + new ReviewRepairJournal(fixture.fixture.fixture.mongo,fixture.fixture.archive)); + assertEquals("APPLIED",executor.stage(prepared,"new-moderator",()->true).state());var before=fixture.fixture.raw(); + var recovered=new ReviewReplacementAdmissionReader(fixture.fixture.fixture.mongo); + assertEquals(List.of(prepared.id()),recovered.held(null,64,()->true).operationIds()); + assertTrue(recovered.hasRequest(prepared.replacement().requestId(),()->true)); + assertEquals(before,fixture.fixture.raw());assertEquals(0,fixture.fixture.fixture.gets.get());assertEquals(0,fixture.fixture.fixture.posts.get()); + } + + @Test void pagesByStableIdentityAndContinuesPastADeletedCursor() { + for(int n:List.of(4,2,1,3))collection().insertOne(new Document("_id",id(n)).append("state","HELD")); + var first=reader.held(null,2,()->true);assertEquals(List.of(id(1),id(2)),first.operationIds());assertEquals("r1."+id(2),first.nextCursor()); + collection().deleteOne(new Document("_id",id(2))); + var second=reader.held(first.nextCursor(),2,()->true);assertEquals(List.of(id(3),id(4)),second.operationIds());assertNull(second.nextCursor()); + assertTrue(reader.held("r1."+id(4),64,()->true).operationIds().isEmpty()); + } + + @Test void arrayStatesMalformedIdsAndOtherStatesAreNotHeldCandidates() { + collection().insertMany(List.of(new Document("_id",id(1)).append("state",List.of("HELD")),new Document("_id",id(2)).append("state","READY"), + new Document("_id","bad").append("state","HELD"),new Document("_id",17).append("state","HELD"),new Document("_id",id(3)).append("state","HELD"))); + assertEquals(List.of(id(3)),reader.held(null,64,()->true).operationIds()); + } + + @Test void discoveryDoesNotAuthenticateForgedAdmissionReferences() { + collection().insertOne(new Document("_id",id(1)).append("state","HELD")); + assertEquals(List.of(id(1)),reader.held(null,64,()->true).operationIds()); + assertThrows(IllegalStateException.class,()->fixture.preparation.recover(id(1),"new-moderator",()->true)); + } + + @Test void evenMalformedRequestReferencesPreventAssumingHistoryIsAbsent() { + String request=UUID.randomUUID().toString();assertFalse(reader.hasRequest(request,()->true)); + collection().insertOne(new Document("_id","damaged").append("requestId",List.of(request))); + assertTrue(reader.hasRequest(request,()->true)); + } + + @Test void missingIndexFailsClosedInsteadOfFallingBackToAnUnboundedScan() { + collection().dropIndex(ReviewReplacementAdmissionReader.REQUEST_INDEX); + assertThrows(com.mongodb.MongoException.class,()->reader.hasRequest(UUID.randomUUID().toString(),()->true)); + var request=fixture.request();assertThrows(com.mongodb.MongoException.class,()->fixture.preparation.prepare(request,()->true)); + assertNull(fixture.fixture.archive.find(request.id())); + } + + @Test void rejectsInvalidBoundsCursorsAndDeniedAccess() { + for(int limit:List.of(0,65))assertThrows(IllegalArgumentException.class,()->reader.held(null,limit,()->true)); + for(String cursor:List.of("r2."+id(1),"r1.bad",id(1)))assertThrows(IllegalArgumentException.class,()->reader.held(cursor,2,()->true)); + assertThrows(SecurityException.class,()->reader.held(null,2,()->false)); + assertThrows(SecurityException.class,()->reader.hasRequest(id(1),()->false)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementEvidenceReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementEvidenceReaderTest.java new file mode 100644 index 000000000..08bc779e3 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementEvidenceReaderTest.java @@ -0,0 +1,87 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementEvidenceReaderTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewRemoteTargetReader current; + ReviewOrphanTargetResolver resolver; + ReviewReplacementEvidenceReader reader; + @BeforeEach void setup()throws Exception { + fixture.setup();new ReviewReplacementAdmissionReader(fixture.fixture.mongo).initialize();current=new ReviewRemoteTargetReader(fixture.fixture.mongo); + resolver=spy(new ReviewOrphanTargetResolver(fixture.fixture.mongo,fixture.archive,fixture.isolation)); + reader=new ReviewReplacementEvidenceReader(current,resolver,new ReviewReplacementHistoryReader(fixture.fixture.mongo,fixture.archive,current)); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewReplacementEvidenceReader.Evidence capture(){return reader.capture(fixture.raw().get("_id"),0,"v",()->true);} + + @Test void ordinaryTerminalCaptureDoesNotRequireOrInventIsolationHistory() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.status","CLEAN");fixture.fixture.change("scanResult.scanState","COMPLETED"); + var before=fixture.raw();var evidence=capture();assertNull(evidence.isolation()); + assertEquals(before,fixture.raw());verifyNoInteractions(resolver); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + + @Test void retainsBothCurrentAndOriginalEvidenceAfterLegitimateMetadataChanges() { + var claim=fixture.fixture.attached(30000);fixture.fixture.change("scanResult.verdict","BLOCK");fixture.isolateExpiredBrokenPoll(); + fixture.fixture.change("changelog","new moderator context");var before=fixture.raw(); + var evidence=capture();assertEquals(claim.binding(),evidence.current().binding()); + assertEquals(claim.binding(),evidence.isolation().binding()); + assertEquals(fixture.prepared.sha256(),evidence.isolation().beforeSha256()); + assertNotEquals(evidence.current().snapshot().sha256(),evidence.isolation().beforeSha256()); + verify(resolver).resolve(fixture.prepared.id(),"actor"); + assertEquals(before,fixture.raw());assertEquals("BLOCK",fixture.fixture.saved().getVerdict()); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + + @ParameterizedTest @ValueSource(strings={"missing","shape","operation","actor","digest","timestamp","job","receipt","archive"}) + void failsClosedOnBrokenHistoryWithoutChangingOrContactingAnything(String corruption) { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll(); + switch(corruption) { + case "missing" -> fixture.fixture.change("reviewIsolation",null); + case "shape" -> fixture.fixture.change("reviewIsolation","broken"); + case "operation" -> fixture.fixture.change("reviewIsolation.operationId",UUID.randomUUID().toString()); + case "actor" -> fixture.fixture.change("reviewIsolation.actorId","another"); + case "digest" -> fixture.fixture.change("reviewIsolation.beforeSha256","f".repeat(64)); + case "timestamp" -> fixture.fixture.change("reviewIsolation.isolatedAt",17L); + case "job" -> fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + case "receipt" -> fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + case "archive" -> fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("actorId","another"))); + } + var before=fixture.raw();assertThrows(RuntimeException.class,this::capture); + assertEquals(before,fixture.raw());assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + + @Test void permissionIsRequiredBeforeReadsAndRecheckedAfterHistoricalRecovery() { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll(); + var observed=spy(current);var guarded=new ReviewReplacementEvidenceReader(observed,resolver,new ReviewReplacementHistoryReader(fixture.fixture.mongo,fixture.archive,current)); + Object id=fixture.raw().get("_id"); + assertThrows(SecurityException.class,()->guarded.capture(id,0,"v",()->false)); + verifyNoInteractions(observed,resolver); + var permitted=new AtomicBoolean(true); + doAnswer(call->{var target=call.callRealMethod();permitted.set(false);return target;}).when(resolver).resolve(fixture.prepared.id(),"actor"); + assertThrows(SecurityException.class,()->guarded.capture(id,0,"v",permitted::get)); + verify(resolver).resolve(fixture.prepared.id(),"actor"); + } + + @ParameterizedTest @ValueSource(strings={"projectType","position"}) + void matchingDisplayIdentityDoesNotOverrideExactHistoricalIdentity(String field) { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll(); + var original=resolver.resolve(fixture.prepared.id(),"actor"); + Object differentId=original.projectId() instanceof org.bson.types.ObjectId oid?oid.toHexString():new org.bson.types.ObjectId((String)original.projectId()); + var changed=new ReviewOrphanTargetResolver.Target(original.isolationId(),field.equals("projectType")?differentId:original.projectId(), + field.equals("position")?1:original.versionIndex(),original.beforeSha256(),original.binding()); + doReturn(changed).when(resolver).resolve(fixture.prepared.id(),"actor"); + assertThrows(IllegalStateException.class,this::capture); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementExecutorTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementExecutorTest.java new file mode 100644 index 000000000..7826f8754 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementExecutorTest.java @@ -0,0 +1,115 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.ClientSession; +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementExecutorTest { + ReviewReplacementPreparationTest fixture=new ReviewReplacementPreparationTest(); + ReviewRepairJournal journal; + @BeforeEach void setup()throws Exception { + fixture.setup();journal=new ReviewRepairJournal(fixture.fixture.fixture.mongo,fixture.fixture.archive); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewReplacementExecutor executor() { + return new ReviewReplacementExecutor(fixture.fixture.fixture.mongo,fixture.preparation,fixture.fixture.reader,journal); + } + ReviewReplacementPreparation.Prepared prepare() {return fixture.preparation.prepare(fixture.request(),()->true);} + Document admission(String id){return fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).find(new Document("_id",id)).first();} + + @ParameterizedTest @ValueSource(booleans={false,true}) + void stagesHeldReplacementAndLineageTogetherWithoutClearingPriorHolds(boolean isolated) { + var f=fixture.fixture.fixture;f.change("scanResult.verdict","BLOCK");f.change("scanResult.holdUntilTimestamp",123456L); + if(isolated)fixture.fixture.isolateExpiredBrokenPoll(); + var before=fixture.fixture.raw();var prepared=prepare();var result=executor().stage(prepared,"new-moderator",()->true); + assertEquals("APPLIED",result.state());assertEquals(result,executor().stage(prepared,"new-moderator",()->true)); + assertEquals(result.afterSha256(),fixture.fixture.reader.capture(before.get("_id"),0,"v").sha256()); + assertEquals("HELD",admission(prepared.id()).get("state"));assertEquals(prepared.beforeArchiveId(),admission(prepared.id()).get("beforeArchiveId")); + var version=f.mongo.findById(f.project,net.modtale.model.project.Project.class).getVersions().getFirst(); + assertEquals(prepared.id(),version.getReviewReplacement().operationId());assertNull(version.getReviewIsolation()); + assertEquals(prepared.replacement(),version.getScanResult().getRemoteReview()); + assertEquals("REPLACEMENT_HELD",version.getScanResult().getScanState());assertEquals("BLOCK",version.getScanResult().getVerdict()); + assertFalse(ArtifactClearancePolicy.boundToVersion(version));assertFalse(version.getScanResult().isArtifactVerified()); + assertEquals(before.getList("versions",Document.class).getFirst().get("scanResult",Document.class).get("holdUntilTimestamp"), + fixture.fixture.raw().getList("versions",Document.class).getFirst().get("scanResult",Document.class).get("holdUntilTimestamp")); + assertTrue(new RemoteReviewDiscovery(f.mongo).page(null,64).candidates().isEmpty()); + assertEquals("NO_WORK",f.step.advance(prepared.replacement()).state());assertEquals(0,f.gets.get());assertEquals(0,f.posts.get()); + } + + @Test void changedVersionCannotBeOverwrittenOrAdmitted() { + var prepared=prepare();fixture.fixture.fixture.change("changelog","concurrent edit");var before=fixture.fixture.raw(); + assertEquals("NOT_APPLIED",executor().stage(prepared,"new-moderator",()->true).state());assertNull(admission(prepared.id())); + assertEquals(before,fixture.fixture.raw()); + } + @ParameterizedTest @ValueSource(strings={"BLOCK","INFECTED","inherited"}) + void stagingRetainsTheEarliestUnresolvedAdverseReference(String reason) { + var f=fixture.fixture.fixture;String inherited=java.util.UUID.randomUUID().toString(); + if(reason.equals("BLOCK"))f.change("scanResult.verdict","BLOCK"); + else if(reason.equals("INFECTED"))f.change("scanResult.status","INFECTED"); + else f.change("replacementSecurityHold",inherited); + var prepared=prepare();assertEquals("APPLIED",executor().stage(prepared,"new-moderator",()->true).state()); + var version=f.mongo.findById(f.project,net.modtale.model.project.Project.class).getVersions().getFirst(); + assertEquals(reason.equals("inherited")?inherited:prepared.id(),version.getReplacementSecurityHold()); + assertEquals("BLOCK",version.getScanResult().getVerdict()); + } + + @Test void admissionInsertFailureRollsBackVersionAndCannotBeReexecuted() { + var prepared=prepare();var before=fixture.fixture.raw(); + fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).insertOne(new Document("_id",prepared.id()).append("state","collision")); + assertEquals("UNKNOWN",executor().stage(prepared,"new-moderator",()->true).state());assertEquals(before,fixture.fixture.raw()); + fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).deleteMany(new Document()); + assertEquals("UNKNOWN",executor().stage(prepared,"new-moderator",()->true).state());assertNull(admission(prepared.id()));assertEquals(before,fixture.fixture.raw()); + } + + @Test void permissionRevocationAfterVersionWriteRollsBackAdmissionAndVersion() { + var prepared=prepare();var before=fixture.fixture.raw();var allowed=new AtomicBoolean(true);var reader=spy(fixture.fixture.reader); + doAnswer(call->{var captured=(RawReviewSnapshotReader.Captured)call.callRealMethod(); + if(!prepared.beforeSha256().equals(captured.sha256()))allowed.set(false);return captured; + }).when(reader).capture(any(ClientSession.class),eq(before.get("_id")),eq(0),eq("v")); + var executor=new ReviewReplacementExecutor(fixture.fixture.fixture.mongo,fixture.preparation,reader,journal); + assertThrows(SecurityException.class,()->executor.stage(prepared,"new-moderator",allowed::get)); + assertEquals(before,fixture.fixture.raw());assertNull(admission(prepared.id())); + } + + @Test void lostCommitReplyRecoversOneAppliedVersionAndAdmission() { + var prepared=prepare();var mongo=spy(fixture.fixture.fixture.mongo);var factory=spy(mongo.getMongoDatabaseFactory()); + doReturn(factory).when(mongo).getMongoDatabaseFactory(); + doAnswer(call->{var session=spy((ClientSession)call.callRealMethod()); + doAnswer(commit->{commit.callRealMethod();throw new MongoException("lost commit reply");}).when(session).commitTransaction();return session; + }).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ReviewReplacementExecutor(mongo,fixture.preparation,fixture.fixture.reader,journal).stage(prepared,"new-moderator",()->true); + assertEquals("APPLIED",result.state());assertEquals(result,executor().stage(prepared,"new-moderator",()->true)); + assertEquals(1,fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).countDocuments()); + } + + @Test void originalJobCannotWriteIntoStagedReplacement() { + var f=fixture.fixture.fixture; + // The fixture has an attached claim already; reconstruct the same current claim from its persisted token. + var old=f.saved().getRemoteReview();var poll=f.saved().getRemotePoll(); + var claim=new RemoteReviewPollStore.Claim(old,poll.token()); + var prepared=prepare();assertEquals("APPLIED",executor().stage(prepared,"new-moderator",()->true).state());var before=fixture.fixture.raw(); + assertFalse(f.completion.handleRemoteCompletedScan(claim,f.cleanResult()));assertFalse(f.polls.release(claim,100)); + assertEquals(before,fixture.fixture.raw()); + } + + @Test void concurrentBsonTypeChangeAfterCaptureAbortsTheTransaction() { + var prepared=prepare();var reader=spy(fixture.fixture.reader);var changed=new AtomicBoolean(); + Object id=fixture.fixture.raw().get("_id"); + doAnswer(call->{var captured=(RawReviewSnapshotReader.Captured)call.callRealMethod(); + if(changed.compareAndSet(false,true))fixture.fixture.fixture.change("scanResult.scanAttempt",1L);return captured; + }).when(reader).capture(any(ClientSession.class),eq(id),eq(0),eq("v")); + var result=new ReviewReplacementExecutor(fixture.fixture.fixture.mongo,fixture.preparation,reader,journal).stage(prepared,"new-moderator",()->true); + assertEquals("UNKNOWN",result.state());assertNull(admission(prepared.id())); + var scan=fixture.fixture.raw().getList("versions",Document.class).getFirst().get("scanResult",Document.class); + assertInstanceOf(Long.class,scan.get("scanAttempt"));assertEquals(fixture.fixture.fixture.binding.requestId(),scan.get("scanRequestId")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementHistoryReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementHistoryReaderTest.java new file mode 100644 index 000000000..e5c2825c7 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementHistoryReaderTest.java @@ -0,0 +1,84 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementHistoryReaderTest { + ReviewReplacementPreparationTest fixture=new ReviewReplacementPreparationTest(); + ReviewReplacementHistoryReader history; + ReviewReplacementPreparation.Prepared first; + @BeforeEach void setup()throws Exception { + fixture.setup();history=new ReviewReplacementHistoryReader(fixture.fixture.fixture.mongo,fixture.fixture.archive,fixture.targets); + first=stage(); + // Admission/remote execution is outside this fixture: simulate its terminal stored binding explicitly. + terminal(); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewReplacementPreparation.Prepared stage() { + var prepared=fixture.preparation.prepare(fixture.request(),()->true); + var executor=new ReviewReplacementExecutor(fixture.fixture.fixture.mongo,fixture.preparation,fixture.fixture.reader, + new ReviewRepairJournal(fixture.fixture.fixture.mongo,fixture.fixture.archive)); + assertEquals("APPLIED",executor.stage(prepared,"new-moderator",()->true).state());return prepared; + } + void terminal() { + var f=fixture.fixture.fixture;f.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + f.change("scanResult.status","INFECTED");f.change("scanResult.scanState","COMPLETED");f.change("scanResult.verdict","BLOCK"); + } + ReviewReplacementEvidenceReader.Evidence evidence() { + return fixture.evidence.capture(fixture.fixture.raw().get("_id"),0,"v",()->true); + } + + @Test void laterGenerationsRetainPreviousSignedReferencesWithoutEmbeddingAnArray() { + var firstLink=evidence().previousReplacement();assertEquals(first.id(),firstLink.operationId());assertNull(firstLink.previousOperationId()); + var second=stage();terminal();var secondLink=evidence().previousReplacement(); + assertEquals(second.id(),secondLink.operationId());assertEquals(first.id(),secondLink.previousOperationId()); + var beforeSecond=new org.bson.RawBsonDocument(fixture.fixture.archive.load(second.beforeArchiveId()).versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + assertEquals(first.id(),beforeSecond.get("reviewReplacement",Document.class).get("operationId")); + assertEquals("BLOCK",beforeSecond.get("scanResult",Document.class).get("verdict")); + assertEquals(3,fixture.fixture.raw().getList("versions",Document.class).getFirst().get("reviewReplacement",Document.class).size()); + assertEquals(0,fixture.fixture.fixture.gets.get());assertEquals(0,fixture.fixture.fixture.posts.get()); + } + + @ParameterizedTest @ValueSource(strings={"missing","null","shape","id","digest","request","extra","receipt","archive","config","origin"}) + void damagedHeadCannotBeSignedIntoAnotherReplacement(String corruption) { + var f=fixture.fixture.fixture; + switch(corruption) { + case "missing" -> f.mongo.getCollection("projects").updateOne(new Document("_id",fixture.fixture.raw().get("_id")),new Document("$unset",new Document("versions.0.reviewReplacement",""))); + case "null" -> f.change("reviewReplacement",null); + case "shape" -> f.change("reviewReplacement","invalid"); + case "id" -> f.change("reviewReplacement.operationId",UUID.randomUUID().toString()); + case "digest" -> f.change("reviewReplacement.beforeSha256","a".repeat(64)); + case "request" -> f.change("reviewReplacement.requestId",UUID.randomUUID().toString()); + case "extra" -> f.change("reviewReplacement.extra",true); + case "receipt" -> f.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",first.beforeArchiveId()),new Document("$set",new Document("state","UNKNOWN"))); + case "archive" -> f.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",first.id()),new Document("$set",new Document("actorId","other"))); + case "config" -> f.change("scanResult.remoteReview.reviewConfigSha256","e".repeat(64)); + case "origin" -> f.change("scanResult.remoteReview.origin.callerScope","e".repeat(64)); + } + var request=fixture.request();var before=fixture.fixture.raw(); + assertThrows(RuntimeException.class,()->fixture.preparation.prepare(request,()->true)); + assertNull(fixture.fixture.archive.find(request.id()));assertEquals(before,fixture.fixture.raw()); + } + + @Test void newIsolationCanPreserveTheReplacementHeadAsWellAsOriginalJobHistory() { + var f=fixture.fixture.fixture;f.change("scanResult.status","SCANNING");f.change("scanResult.scanState","REMOTE_REVIEW"); + fixture.fixture.isolateExpiredBrokenPoll();var result=evidence(); + assertEquals(first.id(),result.previousReplacement().operationId());assertEquals(fixture.fixture.prepared.id(),result.isolation().isolationId()); + var second=stage();assertEquals(fixture.fixture.prepared.id(),second.isolationId()); + } + + @Test void matchingTextCannotReplaceExactProjectBsonIdentity() { + var captured=fixture.targets.capture(fixture.fixture.raw().get("_id"),0,"v"); + var version=fixture.fixture.raw().getList("versions",Document.class).getFirst();Object id=fixture.fixture.raw().get("_id"); + Object alias=id instanceof org.bson.types.ObjectId oid?oid.toHexString():new org.bson.types.ObjectId((String)id); + assertThrows(IllegalStateException.class,()->history.verifyHead(alias,0,captured.binding(),version.get("reviewReplacement"),()->true)); + assertThrows(SecurityException.class,()->history.verifyHead(id,0,captured.binding(),version.get("reviewReplacement"),()->false)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementJobAccountingTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementJobAccountingTest.java new file mode 100644 index 000000000..4047a0325 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementJobAccountingTest.java @@ -0,0 +1,80 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementJobAccountingTest { + ReviewReplacementPreparationTest fixture=new ReviewReplacementPreparationTest(); + ReviewReplacementExecutor executor; + ReviewReplacementPreparation.Prepared prepared; + ReviewReplacementJobAccounting accounting; + String id=UUID.randomUUID().toString();AtomicInteger gets=new AtomicInteger(); + @BeforeEach void setup()throws Exception { + fixture.setup();executor=new ReviewReplacementExecutor(fixture.fixture.fixture.mongo,fixture.preparation,fixture.fixture.reader, + new ReviewRepairJournal(fixture.fixture.fixture.mongo,fixture.fixture.archive)); + prepared=fixture.preparation.prepare(fixture.request(),()->true); + assertEquals("APPLIED",executor.stage(prepared,"new-moderator",()->true).state());accounting=create(); + } + ReviewReplacementJobAccounting create() { + return new ReviewReplacementJobAccounting(fixture.fixture.fixture.mongo,fixture.preparation,executor,fixture.targets, + fixture.fixture.fixture.client,1,Duration.ofSeconds(10)); + } + @AfterEach void cleanup(){accounting.close();fixture.cleanup();} + ReviewReplacementJobAccounting.Receipt check(){return accounting.check(id,prepared,"new-moderator",()->true);} + void route(int code,String state) { + var f=fixture.fixture.fixture;f.route(exchange->{assertEquals("GET",exchange.getRequestMethod()); + assertTrue(exchange.getRequestURI().getPath().endsWith("/"+f.job));gets.incrementAndGet();f.reply(exchange,code,state);}); + } + + @ParameterizedTest @ValueSource(strings={"COMPLETED","CANCELLED","RUNNING","QUEUED","EXPIRED","HELD"}) + void retainsDistinctOriginalStatusWithoutActivatingReplacement(String state) { + route(200,state);var before=fixture.fixture.raw();var result=check(); + assertEquals("OBSERVED",result.state());assertEquals(state,result.observation().status().state()); + assertEquals(fixture.fixture.fixture.job,result.observation().status().jobId());assertNotNull(result.receivedAt()); + try(var restarted=create()){assertEquals(result,restarted.check(id,prepared,"new-moderator",()->true));} + assertEquals(result,accounting.receipt(id,prepared,"new-moderator",()->true));assertEquals(1,gets.get()); + assertEquals(before,fixture.fixture.raw());assertEquals("HELD",fixture.fixture.fixture.mongo.getCollection(ReviewReplacementExecutor.ADMISSIONS).find().first().get("state")); + assertEquals(0,fixture.fixture.fixture.posts.get()); + } + + @ParameterizedTest @ValueSource(ints={404,409,401,403,429,503}) + void missingWrongOriginAndUnavailableRemainSeparateUncertainties(int code) { + route(code,"QUEUED");var result=check(); + String expected=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==503?"UNKNOWN":"UNAVAILABLE"; + assertEquals(expected,result.observation().kind());assertEquals(code,result.observation().httpStatus()); + assertEquals(result,check());assertEquals(1,gets.get()); + } + + @Test void currentVersionDeletionCannotRetargetTheArchivedJob() { + fixture.fixture.fixture.mongo.getCollection("projects").deleteMany(new Document());route(200,"COMPLETED"); + assertEquals(fixture.fixture.fixture.job,check().observation().status().jobId());assertEquals(1,gets.get()); + } + + @Test void anUnappliedOrTamperedProposalCannotCreateAnObservation() { + var collection=fixture.fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION); + collection.updateOne(new Document("_id",prepared.beforeArchiveId()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,this::check); + assertEquals(0,fixture.fixture.fixture.mongo.getCollection(ReviewReplacementJobAccounting.COLLECTION).countDocuments());assertEquals(0,gets.get()); + } + + @Test void deniedOrDifferentActorCannotObserveTheOriginalJob() { + assertThrows(SecurityException.class,()->accounting.check(id,prepared,"new-moderator",()->false)); + assertThrows(IllegalStateException.class,()->accounting.check(id,prepared,"other",()->true)); + assertEquals(0,fixture.fixture.fixture.mongo.getCollection(ReviewReplacementJobAccounting.COLLECTION).countDocuments());assertEquals(0,gets.get()); + } + + @Test void lostHttpReplyIsRetainedWithoutAnotherGet() { + fixture.fixture.fixture.route(exchange->{gets.incrementAndGet();exchange.close();}); + var result=check();assertEquals("UNKNOWN",result.state());assertEquals("UNKNOWN",result.observation().kind()); + assertEquals(result,check());assertEquals(1,gets.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementPreparationTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementPreparationTest.java new file mode 100644 index 000000000..51ca8214c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewReplacementPreparationTest.java @@ -0,0 +1,138 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.RemoteReviewOrigin; +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewReplacementPreparationTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewReplacementEvidenceReader evidence; + ReviewRemoteTargetReader targets; + Clock clock=Clock.fixed(Instant.now(),ZoneOffset.UTC); + ReviewReplacementPreparation preparation; + ReviewReplacementPreparation.Configuration configuration=new ReviewReplacementPreparation.Configuration("warden-3.0.0:"+"a".repeat(64), + "d".repeat(64),new RemoteReviewOrigin("22222222-2222-2222-2222-222222222222","f".repeat(64))); + @BeforeEach void setup()throws Exception { + fixture.setup();new ReviewReplacementAdmissionReader(fixture.fixture.mongo).initialize();fixture.fixture.attached(30000);targets=new ReviewRemoteTargetReader(fixture.fixture.mongo); + evidence=new ReviewReplacementEvidenceReader(targets,new ReviewOrphanTargetResolver(fixture.fixture.mongo,fixture.archive,fixture.isolation),new ReviewReplacementHistoryReader(fixture.fixture.mongo,fixture.archive,targets)); + preparation=create(fixture.archive,clock); + } + ReviewReplacementPreparation create(ReviewSnapshotArchive archive,Clock clock) { + return new ReviewReplacementPreparation(evidence,targets,fixture.reader,archive,clock,60000); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewReplacementPreparation.Request request() { + var snapshot=fixture.reader.capture(fixture.raw().get("_id"),0,"v"); + return new ReviewReplacementPreparation.Request(UUID.randomUUID().toString(),snapshot.projectId(),0,"v",snapshot.sha256(),"new-moderator",configuration); + } + + @ParameterizedTest @ValueSource(booleans={false,true}) + void retainsExactBeforeEvidenceAndStableDistinctNewConfiguration(boolean isolated) { + fixture.fixture.change("scanResult.verdict","BLOCK");if(isolated)fixture.isolateExpiredBrokenPoll(); + var request=request();var before=fixture.raw();var expected=fixture.reader.capture(request.projectId(),0,"v").versionBytes(); + var prepared=preparation.prepare(request,()->true); + assertEquals(prepared,create(fixture.archive,Clock.offset(clock,Duration.ofSeconds(10))).prepare(request,()->true)); + assertEquals(prepared,create(fixture.archive,clock).recover(request.id(),request.actorId(),()->true)); + var retained=fixture.archive.load(prepared.beforeArchiveId());assertArrayEquals(expected,retained.versionBytes()); + assertEquals("new-moderator",retained.actorId());assertEquals(request.expectedSha256(),prepared.beforeSha256()); + assertEquals(configuration.origin(),prepared.replacement().origin());assertEquals(configuration.policyVersion(),prepared.replacement().policyVersion()); + assertEquals(configuration.reviewConfigSha256(),prepared.replacement().reviewConfigSha256()); + assertNotEquals(fixture.fixture.binding.origin(),prepared.replacement().origin()); + assertNotEquals(fixture.fixture.binding.requestId(),prepared.replacement().requestId());assertEquals(2,prepared.replacement().attempt()); + assertNull(prepared.replacement().jobId());assertTrue(prepared.replacement().manualRescan()); + assertEquals(isolated?fixture.prepared.id():null,prepared.isolationId()); + assertEquals(isolated?fixture.prepared.sha256():null,prepared.isolationBeforeSha256()); + assertEquals(before,fixture.raw());assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + + @ParameterizedTest @ValueSource(strings={"actor","config","changedVersion","expired"}) + void retryCannotChangeAnIntentOrExtendItsLifetime(String change) { + var request=request();var prepared=preparation.prepare(request,()->true); + var service=preparation;var retry=request; + switch(change) { + case "actor" -> retry=new ReviewReplacementPreparation.Request(request.id(),request.projectId(),0,"v",request.expectedSha256(),"other",configuration); + case "config" -> retry=new ReviewReplacementPreparation.Request(request.id(),request.projectId(),0,"v",request.expectedSha256(),request.actorId(), + new ReviewReplacementPreparation.Configuration(configuration.policyVersion(),"e".repeat(64),configuration.origin())); + case "changedVersion" -> fixture.fixture.change("changelog","new content"); + case "expired" -> service=create(fixture.archive,Clock.offset(clock,Duration.ofSeconds(60))); + } + var fixedService=service;var fixedRetry=retry; + assertThrows(RuntimeException.class,()->fixedService.prepare(fixedRetry,()->true)); + assertEquals(prepared,service.recover(request.id(),request.actorId(),()->true)); + } + + @Test void recoveryRemainsLocalAfterDeletionButCannotBeUsedByAnotherActor() { + var request=request();var prepared=preparation.prepare(request,()->true); + fixture.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(prepared,preparation.recover(request.id(),request.actorId(),()->true)); + assertThrows(IllegalStateException.class,()->preparation.recover(request.id(),"other",()->true)); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + + @ParameterizedTest @ValueSource(strings={"before","intent"}) + void tamperingEitherRetainedRecordPreventsRecovery(String target) { + var request=request();var prepared=preparation.prepare(request,()->true); + String id=target.equals("before")?prepared.beforeArchiveId():prepared.id(); + fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",id),new Document("$set",new Document("expiresAt",clock.millis()+120000))); + assertThrows(IllegalStateException.class,()->preparation.recover(request.id(),request.actorId(),()->true)); + } + + @ParameterizedTest @ValueSource(strings={"before","intent"}) + void lostRetentionReplyCannotAllocateAnotherRequestOrRefreshExpiry(String stage) { + var archive=spy(fixture.archive);var fail=new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call->{var saved=(ReviewSnapshotArchive.Snapshot)call.callRealMethod(); + boolean selected=(saved.action()==ReviewSnapshotArchive.Action.REPLACEMENT_INTENT)==stage.equals("intent"); + if(selected && fail.getAndSet(false))throw new IllegalStateException("lost reply");return saved; + }).when(archive).retain(any()); + var request=request();var service=create(archive,clock); + if(stage.equals("intent"))assertThrows(IllegalStateException.class,()->service.prepare(request,()->true)); + else service.prepare(request,()->true); + var recovered=preparation.recover(request.id(),request.actorId(),()->true); + assertEquals(recovered,create(fixture.archive,Clock.offset(clock,Duration.ofSeconds(5))).prepare(request,()->true)); + assertEquals(clock.millis()+60000,recovered.expiresAt()); + } + + @Test void changedCurrentVersionDuringRetentionLeavesOnlyRecoverableProposal() { + var archive=spy(fixture.archive); + doAnswer(call->{var saved=(ReviewSnapshotArchive.Snapshot)call.callRealMethod(); + if(saved.action()==ReviewSnapshotArchive.Action.REPLACEMENT_INTENT)fixture.fixture.change("changelog","concurrent edit");return saved; + }).when(archive).retain(any()); + var request=request();assertThrows(IllegalStateException.class,()->create(archive,clock).prepare(request,()->true)); + assertNotNull(preparation.recover(request.id(),request.actorId(),()->true)); + assertEquals("concurrent edit",fixture.raw().getList("versions",Document.class).getFirst().get("changelog")); + assertEquals(fixture.fixture.job,fixture.fixture.saved().getRemoteReview().jobId()); + } + + @Test void deniedAccessCannotRetainOrRecoverEvidence() { + var archive=spy(fixture.archive);var request=request();var service=create(archive,clock); + assertThrows(SecurityException.class,()->service.prepare(request,()->false)); + assertThrows(SecurityException.class,()->service.recover(request.id(),request.actorId(),()->false)); + verifyNoInteractions(archive); + } + + @Test void attemptExhaustionCannotWrapOrRetainAProposal() { + fixture.fixture.change("scanResult.scanAttempt",Integer.MAX_VALUE); + fixture.fixture.change("scanResult.remoteReview.attempt",Integer.MAX_VALUE); + var archive=spy(fixture.archive);var request=request(); + assertThrows(ArithmeticException.class,()->create(archive,clock).prepare(request,()->true)); + verifyNoInteractions(archive); + } + + @Test void revocationDuringRetentionDoesNotReturnPreparationAuthority() { + var permitted=new java.util.concurrent.atomic.AtomicBoolean(true);var archive=spy(fixture.archive); + doAnswer(call->{var saved=(ReviewSnapshotArchive.Snapshot)call.callRealMethod();permitted.set(false);return saved;}).when(archive).retain(any()); + var request=request();var before=fixture.raw(); + assertThrows(SecurityException.class,()->create(archive,clock).prepare(request,permitted::get)); + assertEquals(before,fixture.raw());assertNull(fixture.archive.find(request.id())); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java new file mode 100644 index 000000000..abfa79f7c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java @@ -0,0 +1,28 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +public final class ScanEvidenceFixtures { + public static ScanResult complete(boolean granted) { + ScanResult result = new ScanResult(); + result.setStatus(granted ? ScanStatus.CLEAN : ScanStatus.SUSPICIOUS); + result.setVerdict(granted ? "AUTO_APPROVE" : "REVIEW"); + result.setScanState("COMPLETED"); + result.setArtifactVerified(true); + result.setScanTimestamp(System.currentTimeMillis()); + ScanResult.ScanSummary summary = new ScanResult.ScanSummary(); + summary.setFilesScanned(1); + result.setSummary(summary); + String hash = "a".repeat(64); + result.setSecurityEvidence(new ScanResult.SecurityEvidence("warden-3.0.0:" + "a".repeat(64), "b".repeat(64), + digest("9:Mod.class:" + hash + "\n"), true, granted, "COMPLETED", Map.of("Mod.class", hash))); + return result; + } + static String digest(String text) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); } + catch (Exception e) { throw new IllegalStateException(e); } + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java new file mode 100644 index 000000000..25703ecc0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java @@ -0,0 +1,52 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.modtale.model.project.ScanResult; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import static org.junit.jupiter.api.Assertions.*; + +class ScanEvidencePersistenceTest { + @Test void internalTrustSurvivesDatabaseRoundTripButCannotBeSuppliedOverJson() throws Exception { + var context = new MongoMappingContext(); + context.afterPropertiesSet(); + var converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, context); + converter.setCustomConversions(new net.modtale.config.db.MongoConfig().mongoCustomConversions()); + converter.afterPropertiesSet(); + var original = ScanEvidenceFixtures.complete(true); + var remote=new net.modtale.model.project.RemoteReviewBinding("project","version",java.util.UUID.randomUUID().toString(),1, + "original.zip","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + original.setManualRescan(true); + original.setRemoteReview(remote); + original.setRemoteStatus(new ScanResult.RemoteReviewStatus(java.util.UUID.randomUUID().toString(),"QUEUED",true,1000,2000,null)); + original.setRemotePoll(new ScanResult.RemoteReviewPoll(java.util.UUID.randomUUID().toString(),new java.util.Date(1000),new java.util.Date(2000))); + original.setReviewedContextSha256("c".repeat(64)); + var stored = new Document(); + converter.write(original, stored); + var restored = converter.read(ScanResult.class, stored); + assertTrue(ArtifactClearancePolicy.complete(restored)); + assertEquals(remote,restored.getRemoteReview());assertEquals(remote.origin(),restored.getRemoteReview().origin()); + assertTrue(restored.isManualRescan()); + assertEquals(original.getRemoteStatus(),restored.getRemoteStatus()); + assertEquals(original.getRemotePoll(),restored.getRemotePoll()); + assertEquals(original.getReviewedContextSha256(), restored.getReviewedContextSha256()); + var mapper = new ObjectMapper(); + var forged = mapper.readValue("{\"artifactVerified\":true,\"reviewedContextSha256\":\"forged\"}", ScanResult.class); + assertFalse(forged.isArtifactVerified()); + assertNull(forged.getReviewedContextSha256()); + String json = mapper.writeValueAsString(original); + assertFalse(json.contains("remoteReview")); + assertFalse(json.contains("remotePoll")); + assertFalse(json.contains("remoteStatus")); + assertFalse(json.contains("manualRescan")); + assertFalse(mapper.readValue("{\"manualRescan\":true}",ScanResult.class).isManualRescan()); + assertNull(mapper.readValue("{\"remoteStatus\":{\"state\":\"COMPLETED\"}}",ScanResult.class).getRemoteStatus()); + assertNull(mapper.readValue("{\"remotePoll\":{\"token\":\"forged\"}}",ScanResult.class).getRemotePoll()); + assertNull(mapper.readValue("{\"remoteReview\":"+mapper.writeValueAsString(remote)+"}",ScanResult.class).getRemoteReview()); + assertFalse(json.contains("artifactVerified")); + assertFalse(json.contains("reviewedContextSha256")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java new file mode 100644 index 000000000..b21746f18 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java @@ -0,0 +1,90 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.project.query.ProjectService; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; +import org.mockito.ArgumentCaptor; +import com.mongodb.client.result.UpdateResult; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class ScanPersistenceServiceTest { + @Test void completionIsBoundToAttemptStateAndArtifactHash() { + MongoTemplate mongo=mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class),any(Update.class),eq(Project.class))).thenReturn(UpdateResult.acknowledged(1,1L,null)); + var service=new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)); + var result=ScanEvidenceFixtures.complete(true); + var version = new ProjectVersion(); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + assertTrue(service.applyScanOutcome("project","version",2,result,new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.SCHEDULE,2),version)); + var capture=ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(capture.capture(),any(Update.class),eq(Project.class)); + String query=capture.getValue().getQueryObject().toString(); + assertTrue(query.contains(result.getSecurityEvidence().artifactSha256())); + assertTrue(query.contains("scanAttempt=2")); + assertTrue(query.contains("PENDING")); + assertTrue(query.contains("SCANNING")); + } + @Test void incompleteVersionContextCannotBeScheduledEvenWithCleanArchive() { + var mongo = mock(MongoTemplate.class); + var service = new ScanPersistenceService(mongo, mock(ProjectRepository.class), mock(ProjectService.class)); + var result = ScanEvidenceFixtures.complete(true); + var version = new ProjectVersion(); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + version.setOverrideFileUrl("unreviewed.zip"); + assertFalse(service.applyScanOutcome("project", "version", 1, result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.SCHEDULE, 1), version)); + verifyNoInteractions(mongo); + } + @Test void immediateApprovalStoresCompactEvidenceInTheSameConditionalUpdate() { + var mongo = mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + var service = new ScanPersistenceService(mongo, mock(ProjectRepository.class), mock(ProjectService.class)); + var result = ScanEvidenceFixtures.complete(true); + var version = new ProjectVersion(); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + version.setApprovedSecurityEvidence(result.getSecurityEvidence()); + version.setSecurityApprovedAt(1234); + assertTrue(service.applyScanOutcome("project", "version", 1, result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW, 0), version)); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + var fields = (org.bson.Document) update.getValue().getUpdateObject().get("$set"); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, fields.get("versions.$.reviewStatus")); + assertEquals(1234L, fields.get("versions.$.securityApprovedAt")); + assertTrue(fields.containsKey("versions.$.scanResult")); + assertNull(fields.get("versions.$.scanResult")); + } + @Test void deferredReviewPersistsOneScanObjectAndStaysUnpublished() { + var mongo = mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + var service = new ScanPersistenceService(mongo, mock(ProjectRepository.class), mock(ProjectService.class)); + var result = ScanEvidenceFixtures.complete(false); + assertTrue(service.applyScanOutcome("project", "version", 1, result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.DEFER, 0), new ProjectVersion())); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + var fields = (org.bson.Document) update.getValue().getUpdateObject().get("$set"); + var stored = (ScanResult) fields.get("versions.$.scanResult"); + assertEquals("WAITING_RETRY", stored.getScanState()); + assertEquals(ScanStatus.SCANNING, stored.getStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, fields.get("versions.$.reviewStatus")); + assertTrue(fields.keySet().stream().noneMatch(key -> key.startsWith("versions.$.scanResult."))); + } + @Test void retryCanRecoverAnAbandonedQueuedAttempt() { + MongoTemplate mongo=mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class),any(Update.class),eq(Project.class))).thenReturn(UpdateResult.acknowledged(1,1L,null)); + var service=new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)); + var observed = new ScanResult(); observed.setStatus(ScanStatus.SCANNING); observed.setScanState("QUEUED"); observed.setScanAttempt(1); + service.queueRetryAttempt("project","version",1,new ScanResult(),observed,60_000); + var capture=ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(capture.capture(),any(Update.class),eq(Project.class)); + assertTrue(capture.getValue().getQueryObject().toString().contains("QUEUED")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java index d7b37f387..2754b60e8 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java @@ -33,22 +33,25 @@ void setUp() { service = new ScanRecoveryService(projectService, scanRoutingService, scanPersistenceService, scanCompletionService); } - @Test - void recoverStaleScanningVersionsQueuesRetryWhenAttemptsRemain() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void recoverStaleScanningVersionsQueuesRetryWhenAttemptsRemain(boolean manual) { Project project = project("project-1", version("version-1", "files/mod.jar", 1, staleTimestamp())); - ScanResult queued = new ScanResult(); + project.getVersions().getFirst().getScanResult().setManualRescan(manual); + ScanResult queued = new ScanResult(); queued.setScanRequestId("retry-request"); when(scanRoutingService.scanTimeoutMillis()).thenReturn(60_000L); when(scanRoutingService.scanMaxRetries()).thenReturn(2); when(scanPersistenceService.findProjectsWithScanningVersions()).thenReturn(List.of(project)); when(scanRoutingService.createQueuedScanResult(2, "Previous scan attempt timed out and was re-queued automatically.")) .thenReturn(queued); - when(scanPersistenceService.queueRetryAttempt("project-1", "version-1", 1, queued)).thenReturn(true); + when(scanPersistenceService.queueRetryAttempt("project-1", "version-1", 1, queued, project.getVersions().getFirst().getScanResult(), 60_000L)).thenReturn(true); service.recoverStaleScanningVersions(scheduler); - verify(scheduler).enqueue("project-1", "version-1", "files/mod.jar", "mod.jar", false, 2); - verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + org.junit.jupiter.api.Assertions.assertEquals(manual,queued.isManualRescan()); + verify(scheduler).enqueue("project-1", "version-1", "files/mod.jar", "mod.jar", manual, 2, queued.getScanRequestId()); + verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyLong()); verify(projectService).evictProjectCache(project); } @@ -64,9 +67,9 @@ void recoverStaleScanningVersionsMarksTimedOutWhenRetriesAreExhaustedOrFileIsMis service.recoverStaleScanningVersions(scheduler); - verify(scanCompletionService).handleTimedOutScan("project-1", "version-1", "mod.jar", 3); - verify(scanCompletionService).handleTimedOutScan("project-1", "version-2", "uploaded-artifact", 1); - verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt()); + verify(scanCompletionService).handleTimedOutScan("project-1", "version-1", "mod.jar", 3, exhausted.getScanResult(), 60_000L); + verify(scanCompletionService).handleTimedOutScan("project-1", "version-2", "uploaded-artifact", 1, missingFile.getScanResult(), 60_000L); + verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); } @Test @@ -83,9 +86,9 @@ void recoverStaleScanningVersionsIgnoresFreshOrNonScanningVersions() { service.recoverStaleScanningVersions(scheduler); - verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt()); - verify(scanPersistenceService, never()).queueRetryAttempt(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); - verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); + verify(scanPersistenceService, never()).queueRetryAttempt(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyLong()); + verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyLong()); verify(projectService).evictProjectCache(project); } diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java new file mode 100644 index 000000000..31200e699 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java @@ -0,0 +1,89 @@ +package net.modtale.service.security.scan; + +import java.util.*; +import net.modtale.model.project.ScanResult; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ScanRequestIdentityExecutionTest { + @Test void requestIdentitySurvivesQueueDelayAndReachesCompletion() { + var work=new ArrayList(); var storage=mock(StorageService.class); var warden=mock(WardenClientService.class); + var persistence=mock(ScanPersistenceService.class); var completion=mock(ScanCompletionService.class); + var service=new ScanExecutionService(warden,storage,work::add,persistence,completion,mock(ScanRecoveryService.class)); + when(persistence.markAttemptRunning("p","v",1,"request-a")).thenReturn(true); + byte[] bytes={1}; var result=new ScanResult(); when(storage.download("artifact")).thenReturn(bytes); + when(warden.scanFile(bytes,"mod.jar")).thenReturn(result); + service.enqueueBackgroundScan("p","v","artifact","mod.jar",false,1,"request-a"); + verifyNoInteractions(persistence,storage,completion); + verify(warden,never()).scanFile(any(),any()); + work.getFirst().run(); + verify(completion).handleCompletedScan("p","v",1,false,result,"request-a"); + verify(persistence,never()).markAttemptRunning("p","v",1); + } + @Test void obsoleteRequestCannotStartStorageOrProviderWork() { + var storage=mock(StorageService.class); var warden=mock(WardenClientService.class); + var persistence=mock(ScanPersistenceService.class); var completion=mock(ScanCompletionService.class); + var service=new ScanExecutionService(warden,storage,Runnable::run,persistence,completion,mock(ScanRecoveryService.class)); + service.enqueueBackgroundScan("p","v","artifact","mod.jar",false,1,"obsolete"); + verify(persistence).markAttemptRunning("p","v",1,"obsolete"); + verifyNoInteractions(storage,completion); + verify(warden,never()).scanFile(any(),any()); + } + @Test void workerFailureRetainsTheClaimedRequestIdentity() { + var storage=mock(StorageService.class); var persistence=mock(ScanPersistenceService.class); + var completion=mock(ScanCompletionService.class); var failure=new IllegalStateException("test failure"); + when(persistence.markAttemptRunning("p","v",1,"request-a")).thenReturn(true); + when(storage.download("artifact")).thenThrow(failure); + var service=new ScanExecutionService(mock(WardenClientService.class),storage,Runnable::run,persistence,completion,mock(ScanRecoveryService.class)); + service.enqueueBackgroundScan("p","v","artifact","mod.jar",false,1,"request-a"); + verify(completion).handleScanFailure("p","v","mod.jar",1,failure,"request-a"); + } + @Test void remoteModeLeavesQueuedWorkForSchedulerAndDisablesLegacyRecovery() { + var warden=mock(WardenClientService.class);when(warden.remoteJobsEnabled()).thenReturn(true); + var storage=mock(StorageService.class);var persistence=mock(ScanPersistenceService.class); + var completion=mock(ScanCompletionService.class);var recovery=mock(ScanRecoveryService.class); + var service=new ScanExecutionService(warden,storage,r->{fail("Legacy executor used");},persistence,completion,recovery); + service.enqueueBackgroundScan("p","v","file","mod.zip",false,1,"request"); + service.enqueueBackgroundScan("p","v","file","mod.zip",true,2,"manual-request"); + service.enqueueBackgroundScan("p","v","file","mod.zip",false,1); + service.recoverStaleScanningVersions(); + verifyNoInteractions(storage,persistence,completion,recovery);verify(warden,never()).scanFile(any(),any()); + } + @Test void delayedLegacyTaskRechecksModeBeforeClaiming() { + var warden=mock(WardenClientService.class);var work=new ArrayList(); + var storage=mock(StorageService.class);var persistence=mock(ScanPersistenceService.class); + var completion=mock(ScanCompletionService.class);var recovery=mock(ScanRecoveryService.class); + var service=new ScanExecutionService(warden,storage,work::add,persistence,completion,recovery); + service.enqueueBackgroundScan("p","v","file","mod.zip",false,1,"request"); + when(warden.remoteJobsEnabled()).thenReturn(true);work.getFirst().run(); + verifyNoInteractions(storage,persistence,completion,recovery);verify(warden,never()).scanFile(any(),any()); + } + @Test void legacyModeRetainsRecovery() { + var recovery=mock(ScanRecoveryService.class); + new ScanExecutionService(mock(WardenClientService.class),mock(StorageService.class),Runnable::run, + mock(ScanPersistenceService.class),mock(ScanCompletionService.class),recovery).recoverStaleScanningVersions(); + verify(recovery).recoverStaleScanningVersions(any()); + } + @Test void springBindsModeAndDirectLegacyCallsCannotBypassIt() { + var runner=new org.springframework.boot.test.context.runner.ApplicationContextRunner() + .withBean(net.modtale.config.properties.AppWardenProperties.class,()->new net.modtale.config.properties.AppWardenProperties("http://127.0.0.1:1","fixture",true,3,15)) + .withUserConfiguration(WardenClientService.class); + runner.withPropertyValues("app.warden.jobs.enabled=true").run(context->{ + assertNull(context.getStartupFailure());var client=context.getBean(WardenClientService.class); + assertTrue(client.remoteJobsEnabled()); + assertEquals("Synchronous scans are disabled in remote jobs mode",assertThrows(IllegalStateException.class,()->client.scanFile(null,null)).getMessage()); + }); + runner.run(context->{assertNull(context.getStartupFailure());assertFalse(context.getBean(WardenClientService.class).remoteJobsEnabled());}); + } + @Test void requestIdsAreInternalAndIndependentOfAttemptCounters() throws Exception { + var routing=new ScanRoutingService(null); + var first=routing.createQueuedScanResult(1,"test");var replacement=routing.createQueuedScanResult(1,"test"); + assertNotEquals(first.getScanRequestId(),replacement.getScanRequestId()); + assertEquals(first.getScanRequestId(),UUID.fromString(first.getScanRequestId()).toString()); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + assertFalse(mapper.writeValueAsString(first).contains("scanRequestId")); + assertNull(mapper.readValue("{\"scanRequestId\":\"forged\"}",ScanResult.class).getScanRequestId()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java index 524649dd3..ff719cb21 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java @@ -8,7 +8,8 @@ import net.modtale.model.project.ScanResult; import net.modtale.model.project.ScanStatus; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.VersionReviewPersistence; +import static org.mockito.ArgumentMatchers.any; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; @@ -23,8 +24,24 @@ import static org.mockito.Mockito.when; class ScanRequestServiceTest { + @Test void conflictDoesNotQueueWorkOrMutateTheLoadedVersion() { + var project = new Project(); project.setId("project-1"); + var version = new ProjectVersion(); version.setId("version-1"); version.setFileUrl("files/mod.jar"); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + project.setVersions(List.of(version)); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(projectVersionAccessService.findById(project, "version-1")).thenReturn(version); + when(scanRoutingService.createQueuedScanResult(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyString())).thenReturn(new ScanResult()); + when(reviewPersistence.queueRescan(any(), any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.triggerRescan("project-1", "version-1", new User())); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version.getReviewStatus()); + assertNull(version.getScanResult()); + org.mockito.Mockito.verifyNoInteractions(scanExecutionService); + org.mockito.Mockito.verify(projectService, org.mockito.Mockito.never()).evictProjectCache(any()); + } - private ProjectRepository projectRepository; + private VersionReviewPersistence reviewPersistence; private ProjectService projectService; private ScanThrottleService scanThrottleService; private ScanRoutingService scanRoutingService; @@ -34,14 +51,15 @@ class ScanRequestServiceTest { @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(VersionReviewPersistence.class); + when(reviewPersistence.queueRescan(any(), any())).thenReturn(true); projectService = mock(ProjectService.class); scanThrottleService = mock(ScanThrottleService.class); scanRoutingService = mock(ScanRoutingService.class); projectVersionAccessService = mock(ProjectVersionAccessService.class); scanExecutionService = mock(ScanExecutionService.class); service = new ScanRequestService( - projectRepository, + reviewPersistence, projectService, scanThrottleService, scanRoutingService, @@ -63,6 +81,7 @@ void triggerRescanQueuesPendingScanAndEnqueuesBackgroundWork() { version.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); project.setVersions(List.of(version)); ScanResult queued = new ScanResult(); + queued.setScanRequestId("queued-request"); queued.setStatus(ScanStatus.SCANNING); when(projectService.getRawProjectById("project-1")).thenReturn(project); @@ -74,12 +93,13 @@ void triggerRescanQueuesPendingScanAndEnqueuesBackgroundWork() { service.triggerRescan("project-1", "version-1", user); assertSame(queued, version.getScanResult()); + org.junit.jupiter.api.Assertions.assertTrue(queued.isManualRescan()); assertEquals(ProjectVersion.ReviewStatus.PENDING, version.getReviewStatus()); assertNull(version.getScheduledPublishDate()); verify(scanThrottleService).enforceRescanLimit(user); - verify(projectRepository).save(project); + verify(reviewPersistence).queueRescan(any(), any()); verify(projectService).evictProjectCache(project); - verify(scanExecutionService).enqueueBackgroundScan("project-1", "version-1", "files/mod.jar", "mod.jar", true, 2); + verify(scanExecutionService).enqueueBackgroundScan("project-1", "version-1", "files/mod.jar", "mod.jar", true, 2, queued.getScanRequestId()); } @Test diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanReuseHoldTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanReuseHoldTest.java new file mode 100644 index 000000000..0e46f86ea --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanReuseHoldTest.java @@ -0,0 +1,55 @@ +package net.modtale.service.security.scan; + +import net.modtale.config.properties.AppSecurityProperties; +import net.modtale.model.project.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.communication.*; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.issue.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ScanReuseHoldTest { + private void verifyOutcome(boolean dependencies, boolean currentPolicy) { + var result=ScanEvidenceFixtures.complete(false); + var issue=new ScanResult.ScanIssue();issue.setType("RuntimeExec");issue.setSeverity("HIGH"); + issue.setDescription("Process execution evidence");issue.setFilePath("Mod.class");issue.setLineStart(1);issue.setLineEnd(1); + result.setIssues(new ArrayList<>(List.of(issue))); + var prior=new ProjectVersion();prior.setId("prior");prior.setVersionNumber("1.0"); + prior.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);prior.setHash(result.getSecurityEvidence().artifactSha256()); + prior.setApprovedSecurityEvidence(result.getSecurityEvidence());prior.setApprovedReviewOrigins(Map.of()); + prior.setSecurityApprovalProjectId("p");prior.setSecurityApprovedAt(System.currentTimeMillis()-1000); + var target=new ProjectVersion();target.setId("current");target.setVersionNumber("1.1");target.setHash(prior.getHash()); + if(dependencies) for(var version:List.of(prior,target)) version.setDependencies(List.of(new ProjectDependency("library","Library","1.0"))); + prior.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(prior)); + var project=new Project();project.setId("p");project.setVersions(List.of(prior,target)); + var repository=mock(ProjectRepository.class);when(repository.findById("p")).thenReturn(Optional.of(project)); + var persistence=mock(ScanPersistenceService.class); + var properties=new AppSecurityProperties("fixture",60,120,2,4,15,20,25,2); + var classification=new SecurityIssueClassificationService(properties); + var analysis=new SecurityIssueAnalysisService(classification,new SecurityIssueApprovalService(classification)); + var policy=mock(WardenClientService.class);when(policy.currentPolicyVersion()).thenReturn(currentPolicy?result.getSecurityEvidence().policyVersion():"older-policy"); + var completion=new ScanCompletionService(repository,mock(ProjectService.class),mock(ProjectNotificationService.class), + mock(WebhookService.class),analysis,new ScanRoutingService(properties),persistence,new ProjectVersionAccessService(null),policy); + completion.handleCompletedScan("p","current",1,false,result); + var route=ArgumentCaptor.forClass(ScanRoutingService.RoutingDecision.class); + verify(persistence).applyScanOutcome(eq("p"),eq("current"),eq(1),same(result),route.capture(),same(target)); + if(dependencies || !currentPolicy) { + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW,route.getValue().action()); + assertNull(result.getReusedReviewVersion());assertEquals(0,result.getReusedReviewApprovedAt()); + assertTrue(result.getReusedReviewOrigins()==null || result.getReusedReviewOrigins().isEmpty()); + assertFalse(issue.isResolved());assertFalse(issue.isKnownIssue()); + assertEquals(1,result.getNewIssueCount());assertEquals(0,result.getKnownIssueCount()); + } else { + assertEquals(ScanRoutingService.RoutingAction.SCHEDULE,route.getValue().action()); + assertEquals("1.0",result.getReusedReviewVersion());assertTrue(issue.isResolved());assertTrue(issue.isKnownIssue()); + } + } + @Test void equalPinnedDeclarationsDoNotResolveFindingsWithoutDependencyArtifactProof(){verifyOutcome(true,true);} + @Test void policyMismatchRestoresCurrentFindingClassification(){verifyOutcome(false,false);} + @Test void validReuseStillResolvesIdenticalArtifactFindings(){verifyOutcome(false,true);} +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java index 6469e4d29..b03c6e87d 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java @@ -19,6 +19,29 @@ void setUp() { service = new ScanRoutingService(new AppSecurityProperties("secret", 60, 120, 2, 4, 15, 20, 25, 2)); } + @Test void temporaryCapacityFailuresAreRetriedButDoNotClearOrRetryForever() { + for (String state : java.util.List.of("RATE_LIMITED", "TIMEOUT", "UPSTREAM_ERROR")) { + var result = ScanEvidenceFixtures.complete(false); + var evidence = result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), + evidence.contentSha256(), true, false, state, evidence.entryHashes())); + result.setScanAttempt(1); + assertEquals(ScanRoutingService.RoutingAction.DEFER, service.decideRouting(result, stats(false, 0), false).action()); + result.setScanAttempt(3); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(false, 0), false).action()); + result.setScanAttempt(1); result.setVerdict("BLOCK"); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(false, 0), false).action()); + } + } + @Test void permanentCapacityAndAuthenticationFailuresAreNotAutomaticallyRepeated() { + for (String state : java.util.List.of("REQUEST_EXCEEDS_CAPACITY", "AUTHENTICATION_ERROR", "COMPLETED", "INCOMPLETE_COVERAGE")) { + var result = ScanEvidenceFixtures.complete(false); + var evidence = result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), + evidence.contentSha256(), true, false, state, evidence.entryHashes())); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(false, 0), false).action()); + } + } @Test void createQueuedScanResultNormalizesAttemptsAndOptionalNotes() { ScanResult result = service.createQueuedScanResult(0, "Manual rescan requested."); @@ -56,21 +79,20 @@ void decideRoutingRequiresReviewForBlocksAndEscalatedManualReviews() { } @Test - void decideRoutingSchedulesKnownOnlyReviewButApprovesCleanManualRescans() { + void familiarWarningsDoNotAuthorizeReleaseButVerifiedClearanceDoes() { ScanResult review = scan("REVIEW", ScanStatus.SUSPICIOUS, null); - ScanResult clean = scan("ALLOW", ScanStatus.CLEAN, null); + ScanResult clean = ScanEvidenceFixtures.complete(true); ScanRoutingService.RoutingDecision reviewDecision = service.decideRouting(review, stats(true, 0), false); ScanRoutingService.RoutingDecision manualDecision = service.decideRouting(clean, stats(false, 0), true); - assertEquals(ScanRoutingService.RoutingAction.SCHEDULE, reviewDecision.action()); - assertTrue(reviewDecision.delayMinutes() >= 15 && reviewDecision.delayMinutes() <= 20); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, reviewDecision.action()); assertEquals(ScanRoutingService.RoutingAction.APPROVE_NOW, manualDecision.action()); } @Test void decideRoutingSchedulesCleanScansUsingWardenHoldWhenItIsLongerThanRandomDelay() { - ScanResult clean = scan("ALLOW", ScanStatus.CLEAN, null); + ScanResult clean = ScanEvidenceFixtures.complete(true); clean.setHoldUntilTimestamp(System.currentTimeMillis() + 30 * 60_000L); ScanRoutingService.RoutingDecision decision = service.decideRouting(clean, stats(false, 0), false); @@ -112,6 +134,23 @@ void exposesTimeoutAndRetrySettingsWithMinimums() { assertEquals(2, service.scanMaxRetries()); } + @Test + void malformedAndIncompleteEvidenceAlwaysRequiresReviewIncludingManualRescans() { + for (boolean manual : new boolean[]{false, true}) { + for (String state : new String[]{"", "INCOMPLETE", "UPSTREAM_DISABLED", "UPSTREAM_UNAVAILABLE", "QUEUED"}) { + ScanResult result = ScanEvidenceFixtures.complete(true); + result.setScanState(state); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(true, 0), manual).action()); + } + ScanResult result = ScanEvidenceFixtures.complete(true); + result.getSummary().setRecoverableErrors(1); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(true, 0), manual).action()); + result = ScanEvidenceFixtures.complete(true); + result.setArtifactVerified(false); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(true, 0), manual).action()); + } + } + private static ScanResult scan(String verdict, ScanStatus status, String state) { ScanResult scanResult = new ScanResult(); scanResult.setVerdict(verdict); diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java index 5dd714c71..5fe51adfe 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java @@ -5,7 +5,8 @@ import net.modtale.model.project.ProjectVersion; import net.modtale.model.project.ScanResult; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.VersionReviewPersistence; +import static org.mockito.ArgumentMatchers.any; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; @@ -24,7 +25,7 @@ class ScanServiceTest { private ScanService service; private ScanRequestService scanRequestService; private ScanExecutionService scanExecutionService; - private ProjectRepository projectRepository; + private VersionReviewPersistence reviewPersistence; private ProjectService projectService; private ProjectVersionAccessService projectVersionAccessService; private ScanThrottleService scanThrottleService; @@ -32,14 +33,15 @@ class ScanServiceTest { @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(VersionReviewPersistence.class); + when(reviewPersistence.queueRescan(any(), any())).thenReturn(true); projectService = mock(ProjectService.class); projectVersionAccessService = mock(ProjectVersionAccessService.class); scanThrottleService = mock(ScanThrottleService.class); scanRoutingService = mock(ScanRoutingService.class); scanExecutionService = mock(ScanExecutionService.class); scanRequestService = new ScanRequestService( - projectRepository, + reviewPersistence, projectService, scanThrottleService, scanRoutingService, @@ -64,6 +66,7 @@ void triggerRescanRequeuesTheVersionAndSchedulesAnotherBackgroundAttempt() { project.setVersions(List.of(version)); ScanResult pendingScan = new ScanResult(); + pendingScan.setScanRequestId("queued-request"); when(projectService.getRawProjectById("project-1")).thenReturn(project); when(projectVersionAccessService.findById(project, "version-1")).thenReturn(version); @@ -76,7 +79,7 @@ void triggerRescanRequeuesTheVersionAndSchedulesAnotherBackgroundAttempt() { assertEquals(pendingScan, version.getScanResult()); assertEquals(ProjectVersion.ReviewStatus.PENDING, version.getReviewStatus()); verify(scanThrottleService).enforceRescanLimit(user); - verify(projectRepository).save(project); + verify(reviewPersistence).queueRescan(any(), any()); verify(projectService).evictProjectCache(project); ArgumentCaptor originalFilename = ArgumentCaptor.forClass(String.class); verify(scanExecutionService).enqueueBackgroundScan( @@ -85,7 +88,7 @@ void triggerRescanRequeuesTheVersionAndSchedulesAnotherBackgroundAttempt() { eq("https://cdn.modtale.net/files/generated-mod.jar"), originalFilename.capture(), eq(true), - eq(2) + eq(2), eq(pendingScan.getScanRequestId()) ); assertTrue(originalFilename.getValue().endsWith("generated-mod.jar")); } diff --git a/backend/src/test/java/net/modtale/service/security/scan/VersionMutationExecutorTest.java b/backend/src/test/java/net/modtale/service/security/scan/VersionMutationExecutorTest.java new file mode 100644 index 000000000..0e4fb1d1d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/VersionMutationExecutorTest.java @@ -0,0 +1,80 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.ClientSession; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class VersionMutationExecutorTest { + VersionMutationPreparationTest base=new VersionMutationPreparationTest();ReviewRepairJournal journal; + @BeforeEach void setup()throws Exception{base.setup();journal=new ReviewRepairJournal(base.base.fixture.mongo,base.base.archive);} + @AfterEach void cleanup(){base.cleanup();} + VersionMutationExecutor executor(){return new VersionMutationExecutor(base.base.fixture.mongo,base.service,base.base.archive,journal,base.base.reader);} + ProjectVersion version(){return base.base.fixture.mongo.findById(base.base.fixture.project,Project.class).getVersions().getFirst();} + @Test void contextMutationAndReferenceCommitTogetherWithoutDispatch(){ + var request=base.request(false);var prepared=base.service.prepare(request,()->true);var result=executor().apply(prepared,"owner",()->true); + assertEquals("APPLIED",result.state());var v=version();assertEquals(List.of("new-runtime"),v.getGameVersions()); + assertEquals("MUTATION_HELD",v.getScanResult().getScanState());assertEquals(2,v.getScanResult().getScanAttempt()); + assertEquals(prepared.id(),v.getVersionMutation().operationId());assertEquals(ProjectVersion.ReviewStatus.PENDING,v.getReviewStatus()); + assertNull(v.getApprovedSecurityEvidence());assertEquals(result,executor().apply(prepared,"owner",()->true)); + assertEquals(1,base.base.fixture.mongo.getCollection(VersionMutationExecutor.REFERENCES).countDocuments()); + assertTrue(new RemoteReviewDiscovery(base.base.fixture.mongo).page(null,64).candidates().isEmpty()); + var writer=new VersionReviewPersistence(base.base.fixture.mongo);var snap=writer.capture(base.base.fixture.project,"v",VersionReviewSnapshot.token(v)); + v.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);assertThrows(org.springframework.web.server.ResponseStatusException.class,()->writer.apply(snap,v)); + assertEquals(0,base.base.fixture.posts.get());assertEquals(0,base.base.fixture.gets.get()); + } + @Test void removalRetainsDiscoverableOriginalEvidence(){ + var f=base.base.fixture;f.mongo.getCollection("projects").updateOne(new Document("_id",base.base.raw().get("_id")),new Document("$set",new Document("status","PRIVATE"))); + var request=base.request(true);var prepared=base.service.prepare(request,()->true);assertEquals("APPLIED",executor().apply(prepared,"owner",()->true).state()); + assertTrue(f.mongo.findById(f.project,Project.class).getVersions().isEmpty());var reference=f.mongo.getCollection(VersionMutationExecutor.REFERENCES).find().first(); + assertEquals("REMOVED",reference.get("state"));assertEquals(prepared.beforeArchiveId(),reference.get("beforeArchiveId")); + assertNotNull(base.service.recover(prepared.id(),"owner",()->true).before());assertEquals("APPLIED",executor().apply(prepared,"owner",()->true).state()); + } + @Test void originalBlockSurvivesAnOwnerContextChange(){base.base.fixture.change("scanResult.verdict","BLOCK");var prepared=base.service.prepare(base.request(false),()->true);assertEquals("APPLIED",executor().apply(prepared,"owner",()->true).state());assertEquals(prepared.id(),version().getReplacementSecurityHold());assertEquals("BLOCK",version().getScanResult().getVerdict());} + @Test void ownerProposalCannotModifyNonEditableAuthorityFields(){ + var r=base.request(false);var desired=new RawBsonDocument(r.proposedVersion()).decode(new DocumentCodec());desired.put("hash","d".repeat(64)); + var changed=new VersionMutationPreparation.Request(r.id(),r.projectId(),0,"v",r.expectedSha256(),"owner",r.mutation(),0,VersionMutationPreparationTest.bytes(desired)); + var prepared=base.service.prepare(changed,()->true);var before=base.base.raw();assertThrows(IllegalStateException.class,()->executor().apply(prepared,"owner",()->true)); + assertEquals(before,base.base.raw());assertEquals(0,base.base.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).countDocuments()); + } + @Test void withdrawnLifecycleRecordsNoMutation(){var prepared=base.service.prepare(base.request(false),()->true);base.base.fixture.mongo.getCollection("projects").updateOne(new Document("_id",base.base.raw().get("_id")),new Document("$set",new Document("status","DELETED")));assertEquals("NOT_APPLIED",executor().apply(prepared,"owner",()->true).state());assertEquals(0,base.base.fixture.mongo.getCollection(VersionMutationExecutor.REFERENCES).countDocuments());} + @Test void changedVersionRecordsNoMutation(){var prepared=base.service.prepare(base.request(false),()->true);base.base.fixture.change("changelog","concurrent");assertEquals("NOT_APPLIED",executor().apply(prepared,"owner",()->true).state());assertEquals("concurrent",version().getChangelog());} + @Test void authorityLossAfterWriteRollsBackVersionAndReference(){ + var prepared=base.service.prepare(base.request(false),()->true);var reader=spy(base.base.reader);var allowed=new AtomicBoolean(true);var before=base.base.raw(); + doAnswer(call->{var value=(RawReviewSnapshotReader.Captured)call.callRealMethod();if(!value.sha256().equals(prepared.beforeSha256()))allowed.set(false);return value;}) + .when(reader).capture(any(ClientSession.class),eq(before.get("_id")),eq(0),eq("v")); + var writer=new VersionMutationExecutor(base.base.fixture.mongo,base.service,base.base.archive,journal,reader); + assertThrows(SecurityException.class,()->writer.apply(prepared,"owner",allowed::get));assertEquals(before,base.base.raw());assertEquals(0,base.base.fixture.mongo.getCollection(VersionMutationExecutor.REFERENCES).countDocuments()); + } + @Test void lostCommitReplyRecoversOneAppliedMutation(){ + var prepared=base.service.prepare(base.request(false),()->true);var mongo=spy(base.base.fixture.mongo);var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(mongo).getMongoDatabaseFactory(); + doAnswer(call->{var session=spy((ClientSession)call.callRealMethod());doAnswer(commit->{commit.callRealMethod();throw new MongoException("lost commit reply");}).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new VersionMutationExecutor(mongo,base.service,base.base.archive,journal,base.base.reader).apply(prepared,"owner",()->true); + assertEquals("APPLIED",result.state());assertEquals(result,executor().apply(prepared,"owner",()->true));assertEquals(1,mongo.getCollection(VersionMutationExecutor.REFERENCES).countDocuments()); + } + @Test void exhaustedOrCoercibleAttemptCannotResetTheScanCounter(){ + for(Object attempt:List.of(Integer.MAX_VALUE,4294967297L,1.0,"1")){ + base.base.fixture.change("scanResult.scanAttempt",attempt);var prepared=base.service.prepare(base.request(false),()->true); + assertThrows(IllegalStateException.class,()->executor().apply(prepared,"owner",()->true)); + } + assertEquals(0,base.base.fixture.mongo.getCollection(VersionMutationExecutor.REFERENCES).countDocuments()); + } + @Test void mutationPointerIsPrivateAndFencesManualRescan()throws Exception{ + var prepared=base.service.prepare(base.request(false),()->true);executor().apply(prepared,"owner",()->true);var version=version(); + String token=VersionReviewSnapshot.token(version);var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + assertFalse(mapper.writeValueAsString(version).contains("versionMutation")); + var writer=new VersionReviewPersistence(base.base.fixture.mongo);var snapshot=writer.captureForRescan(base.base.fixture.project,"v",VersionReviewSnapshot.rescanToken(version)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->writer.queueRescan(snapshot,new ScanResult())); + version.setVersionMutation(null);assertNotEquals(token,VersionReviewSnapshot.token(version)); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/VersionMutationPreparationTest.java b/backend/src/test/java/net/modtale/service/security/scan/VersionMutationPreparationTest.java new file mode 100644 index 000000000..9e4fd58a0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/VersionMutationPreparationTest.java @@ -0,0 +1,87 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class VersionMutationPreparationTest { + RemoteReviewIsolationIntegrationTest base=new RemoteReviewIsolationIntegrationTest(); + Clock clock=Clock.fixed(Instant.now(),ZoneOffset.UTC);VersionMutationPreparation service; + @BeforeEach void setup()throws Exception {base.setup();base.fixture.attached(30000);service=create(base.archive,clock);} + @AfterEach void cleanup(){base.cleanup();} + VersionMutationPreparation create(ReviewSnapshotArchive archive,Clock time){return new VersionMutationPreparation(base.reader,archive,time,60000);} + VersionMutationPreparation.Request request(boolean removal) { + var captured=base.reader.capture(base.raw().get("_id"),0,"v");var next=new RawBsonDocument(captured.versionBytes()).decode(new DocumentCodec()); + next.put("gameVersions",List.of("new-runtime"));next.put("scanResult",new Document("scanState","QUEUED").append("scanRequestId",UUID.randomUUID().toString())); + return new VersionMutationPreparation.Request(UUID.randomUUID().toString(),captured.projectId(),0,"v",captured.sha256(),"owner", + removal?VersionMutationPreparation.Mutation.REMOVAL:VersionMutationPreparation.Mutation.CONTEXT_EDIT,removal?-1:0,removal?null:bytes(next)); + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void retainsExactBeforeAndProposedBytesWithoutApplyingMutation(boolean removal) { + var request=request(removal);var before=base.raw();var prepared=service.prepare(request,()->true); + assertEquals(prepared,service.prepare(request,()->true));var recovered=service.recover(request.id(),"owner",()->true); + assertEquals(prepared,recovered.prepared());assertArrayEquals(base.reader.capture(request.projectId(),0,"v").versionBytes(),recovered.before().versionBytes()); + if(removal)assertNull(recovered.after());else assertArrayEquals(request.proposedVersion(),recovered.after().versionBytes()); + assertEquals(before,base.raw());assertEquals(0,base.fixture.posts.get());assertEquals(0,base.fixture.gets.get()); + } + @Test void recoveryAfterDeletionAndExpiryDoesNotRefreshOrRecapture() { + var request=request(false);var prepared=service.prepare(request,()->true);base.fixture.mongo.getCollection("projects").deleteMany(new Document()); + var later=create(base.archive,Clock.offset(clock,Duration.ofDays(1))); + assertEquals(prepared,later.recover(request.id(),"owner",()->true).prepared()); + assertThrows(RuntimeException.class,()->later.prepare(request,()->true));assertThrows(IllegalStateException.class,()->later.recover(request.id(),"another",()->true)); + } + @ParameterizedTest @ValueSource(strings={"actor","desired","mutation"}) + void retryCannotReplaceTheProposedMutation(String change) { + var request=request(false);var prepared=service.prepare(request,()->true);var next=new RawBsonDocument(request.proposedVersion()).decode(new DocumentCodec());next.put("changelog","different"); + var retry=new VersionMutationPreparation.Request(request.id(),request.projectId(),0,"v",request.expectedSha256(),change.equals("actor")?"another":request.actor(), + change.equals("mutation")?VersionMutationPreparation.Mutation.TARGET_REPLACEMENT:request.mutation(),0,change.equals("desired")?bytes(next):request.proposedVersion()); + assertThrows(RuntimeException.class,()->service.prepare(retry,()->true));assertEquals(prepared,service.recover(request.id(),"owner",()->true).prepared()); + } + @ParameterizedTest @ValueSource(strings={"before","after","intent"}) + void lostArchiveReplyRecoversTheSameProposal(String part) { + var archive=spy(base.archive);var once=new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call->{var value=(ReviewSnapshotArchive.Snapshot)call.callRealMethod(); + if(value.action().name().endsWith(part.toUpperCase(Locale.ROOT)) && !once.getAndSet(true))throw new IllegalStateException("lost archive acknowledgement");return value;}) + .when(archive).retain(any()); + var request=request(false);var prepared=create(archive,clock).prepare(request,()->true);assertTrue(once.get());assertEquals(prepared,service.prepare(request,()->true)); + } + @ParameterizedTest @ValueSource(strings={"before","after","intent"}) + void tamperedArchiveCannotBeRecovered(String part) { + var request=request(false);var prepared=service.prepare(request,()->true);var id=switch(part){case "before"->prepared.beforeArchiveId();case "after"->prepared.afterArchiveId();default->prepared.id();}; + base.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",id),new Document("$set",new Document("actorId","different"))); + assertThrows(IllegalStateException.class,()->service.recover(request.id(),"owner",()->true)); + } + @Test void changedVersionAndRevokedAccessCannotYieldAnApplicablePreparation() { + var request=request(false);var archive=spy(base.archive); + doAnswer(call->{var value=call.callRealMethod();base.fixture.change("changelog","concurrent");return value;}).when(archive).retain(any()); + assertThrows(IllegalStateException.class,()->create(archive,clock).prepare(request,()->true)); + assertThrows(SecurityException.class,()->service.recover(request.id(),"owner",()->false)); + assertEquals("concurrent",base.raw().getList("versions",Document.class).getFirst().get("changelog")); + } + @Test void unchangedOrMetadataOnlyMutationDoesNotRetainAnything() { + var request=request(false);var old=base.reader.capture(request.projectId(),0,"v");var next=new RawBsonDocument(old.versionBytes()).decode(new DocumentCodec());next.put("changelog","metadata only"); + var metadata=new VersionMutationPreparation.Request(request.id(),request.projectId(),0,"v",request.expectedSha256(),"owner",request.mutation(),0,bytes(next)); + assertThrows(IllegalArgumentException.class,()->service.prepare(metadata,()->true));assertEquals(0,base.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void expiryCannotBeExtendedByRetryingAnExistingProposal() { + var request=request(false);var prepared=service.prepare(request,()->true); + var later=create(base.archive,Clock.offset(clock,Duration.ofMinutes(2))); + assertThrows(IllegalStateException.class,()->later.prepare(request,()->true));assertEquals(prepared,later.recover(request.id(),"owner",()->true).prepared()); + } + @Test void malformedProposedBytesCannotBeSignedAndRequestBytesAreDefensive() { + var request=request(false);var original=request.proposedVersion();var copy=request.proposedVersion();copy[0]=0;assertArrayEquals(original,request.proposedVersion()); + var trailing=Arrays.copyOf(original,original.length+1); + var malformed=new VersionMutationPreparation.Request(request.id(),request.projectId(),0,"v",request.expectedSha256(),"owner",request.mutation(),0,trailing); + assertThrows(RuntimeException.class,()->service.prepare(malformed,()->true));assertEquals(0,base.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + static byte[] bytes(Document doc){var buffer=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();var bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/VersionMutationReferenceReaderTest.java b/backend/src/test/java/net/modtale/service/security/scan/VersionMutationReferenceReaderTest.java new file mode 100644 index 000000000..f3cca6d30 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/VersionMutationReferenceReaderTest.java @@ -0,0 +1,64 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class VersionMutationReferenceReaderTest { + VersionMutationExecutorTest base=new VersionMutationExecutorTest();VersionMutationReferenceReader reader; + @BeforeEach void setup()throws Exception{base.setup();reader=create();reader.initialize();} + @AfterEach void cleanup(){base.cleanup();} + VersionMutationReferenceReader create(){return new VersionMutationReferenceReader(base.base.base.fixture.mongo,base.base.base.archive,base.base.service,base.executor());} + Object project(){return base.base.base.raw().get("_id");} + org.springframework.data.mongodb.core.MongoTemplate mongo(){return base.base.base.fixture.mongo;} + @ParameterizedTest @ValueSource(booleans={false,true}) + void appliedHistoryRemainsReadableAfterVisibleProjectDeletion(boolean removal){ + var id=project();if(removal)mongo().getCollection("projects").updateOne(new Document("_id",id),new Document("$set",new Document("status","PRIVATE"))); + var prepared=base.base.service.prepare(base.base.request(removal),()->true);base.executor().apply(prepared,"owner",()->true); + assertEquals(List.of(prepared.id()),reader.page(id,null,64,()->true).operationIds());mongo().getCollection("projects").deleteMany(new Document()); + var history=create().read(id,prepared.id(),()->true);assertEquals(prepared,history.evidence().prepared());assertEquals("APPLIED",history.receipt().state());assertEquals(removal?"REMOVED":"HELD",history.state()); + assertEquals(0,base.base.base.fixture.posts.get());assertEquals(0,base.base.base.fixture.gets.get()); + } + @Test void paginationIsExclusiveAndBoundToExactProjectBsonIdentity(){ + var id=project();var refs=mongo().getCollection(VersionMutationExecutor.REFERENCES);var ids=new ArrayList();for(int i=0;i<3;i++)ids.add(UUID.randomUUID().toString());Collections.sort(ids); + for(var op:ids)refs.insertOne(new Document("_id",op).append("projectId",id).append("state","HELD")); + refs.insertOne(new Document("_id",UUID.randomUUID().toString()).append("projectId",id.toString()).append("state","HELD")); + var first=reader.page(id,null,2,()->true);assertEquals(ids.subList(0,2),first.operationIds());assertNotNull(first.nextCursor()); + var second=reader.page(id,first.nextCursor(),2,()->true);assertEquals(List.of(ids.getLast()),second.operationIds());assertNull(second.nextCursor()); + assertThrows(IllegalArgumentException.class,()->reader.page(id.toString(),first.nextCursor(),2,()->true)); + assertThrows(RuntimeException.class,()->reader.read(id,ids.getFirst(),()->true)); + } + @ParameterizedTest @ValueSource(strings={"actor","state","digest","extra","receipt"}) + void inventoryDoesNotAuthenticateTamperedHistory(String change){ + var id=project();var prepared=base.base.service.prepare(base.base.request(false),()->true);base.executor().apply(prepared,"owner",()->true); + var collection=change.equals("receipt")?ReviewRepairJournal.COLLECTION:VersionMutationExecutor.REFERENCES; + var field=switch(change){case "actor"->"actor";case "state","receipt"->"state";case "digest"->"beforeSha256";default->"extra";}; + mongo().getCollection(collection).updateOne(new Document("_id",prepared.id()),new Document("$set",new Document(field,"changed"))); + assertThrows(RuntimeException.class,()->reader.read(id,prepared.id(),()->true)); + } + @Test void missingIndexAndRevokedPermissionFailClosed(){ + var id=project();assertThrows(SecurityException.class,()->reader.page(id,null,1,()->false)); + mongo().getCollection(VersionMutationExecutor.REFERENCES).dropIndex(VersionMutationReferenceReader.INDEX); + assertThrows(com.mongodb.MongoException.class,()->reader.page(id,null,1,()->true)); + assertThrows(SecurityException.class,()->reader.read(id,UUID.randomUUID().toString(),()->false)); + } + @Test void malformedInventoryCannotMasqueradeAsAValidScopedRecord(){ + var id=project();var refs=mongo().getCollection(VersionMutationExecutor.REFERENCES); + refs.insertOne(new Document("_id",UUID.randomUUID().toString()).append("projectId",List.of(id)).append("state","HELD")); + refs.insertOne(new Document("_id",UUID.randomUUID().toString()).append("projectId",id).append("state",List.of("HELD"))); + refs.insertOne(new Document("_id","invalid-id").append("projectId",id).append("state","HELD")); + assertTrue(reader.page(id,null,64,()->true).operationIds().isEmpty()); + assertThrows(IllegalArgumentException.class,()->reader.page(id,null,65,()->true)); + } + @Test void accessRevokedDuringPageReadPreventsReturningInventory(){ + var calls=new java.util.concurrent.atomic.AtomicInteger();var id=project(); + assertThrows(SecurityException.class,()->reader.page(id,null,1,()->calls.getAndIncrement()==0));assertEquals(2,calls.get()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java b/backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java new file mode 100644 index 000000000..b22760577 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java @@ -0,0 +1,33 @@ +package net.modtale.service.security.scan; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import net.modtale.config.properties.AppWardenProperties; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class WardenInspectionWindowClientTest { + @Test void sendsAuthenticatedWindowParametersAndDecodesTheBoundedResponse() throws Exception { + var server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0); + var body=new AtomicReference();var key=new AtomicReference(); + var response=new WardenClientService.InspectionWindow("a".repeat(64),"nested.jar!/Entry.class","b".repeat(64),"policy", + "c".repeat(64),"JVM_BYTECODE",0,4,4,1,true,true,"text",List.of()); + byte[] json=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(response); + server.createContext("/api/v1/inspect-window",exchange->{ + body.set(new String(exchange.getRequestBody().readAllBytes(),StandardCharsets.UTF_8)); + key.set(exchange.getRequestHeaders().getFirst("X-Warden-Api-Key")); + exchange.getResponseHeaders().set("Content-Type","application/json");exchange.sendResponseHeaders(200,json.length); + try(var output=exchange.getResponseBody()){output.write(json);} + });server.start(); + try { + var client=new WardenClientService(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"test-only",true,1,90)); + assertEquals(response,client.inspectWindow("artifact".getBytes(),"nested.jar!/Entry.class",0,32000,42)); + assertEquals("test-only",key.get()); + for(String field:List.of("file","path","offset","characters","sourceLine"))assertTrue(body.get().contains("name=\""+field+"\"")); + for(String value:List.of("artifact","nested.jar!/Entry.class","32000","42"))assertTrue(body.get().contains(value)); + } finally {server.stop(0);} + } +} diff --git a/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java b/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java index afe3800a7..4b3840e8b 100644 --- a/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java +++ b/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java @@ -4,8 +4,7 @@ import net.modtale.model.project.*; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; -import net.modtale.repository.project.ProjectRepository; -import net.modtale.service.analytics.ScoringService; +import net.modtale.service.admin.review.*; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; @@ -20,18 +19,56 @@ class ProjectSocialServiceTest { private final ProjectService projects = mock(ProjectService.class); private final NotificationService notifications = mock(NotificationService.class); private final SanitizationService sanitizer = mock(SanitizationService.class); - - private final ProjectSocialService service = new ProjectSocialService(mock(ProjectRepository.class), users, projects, notifications, - sanitizer, mock(ScoringService.class), repository); - @Test void favoriteFailureDoesNotEvictAndSuccessUsesCanonicalProjectId() { - var project = new Project(); project.setId("canonical"); + private final ProjectReviewPersistence writes = mock(ProjectReviewPersistence.class); + private final ProjectSocialService service = new ProjectSocialService(repository, users, projects, notifications, + sanitizer, writes); + private ProjectReviewPersistence.Snapshot setup() { + var project = new Project(); project.setId("canonical"); project.setAuthorId("author"); project.setAllowComments(true); + project.setComments(new ArrayList<>(List.of(new Comment("user", "old")))); + var user = new User(); user.setId("user"); user.setUsername("User"); + when(users.findById("user")).thenReturn(Optional.of(user)); when(projects.getRawProjectByRouteKey("slug")).thenReturn(project); + var snapshot = new ProjectReviewPersistence.Snapshot(new Document(), project); + when(writes.capture(eq("canonical"), anyString())).thenReturn(snapshot); + when(sanitizer.sanitizePlainText(anyString())).thenAnswer(inv -> inv.getArgument(0)); + return snapshot; + } + @Test void allCommentWritesFailBeforeCacheOrNotificationOnConflict() { + for (int action = 0; action < 4; action++) { + var snapshot = setup(); String id = snapshot.project().getComments().getFirst().getId(); + int selected = action; + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> { + switch (selected) { + case 0 -> service.addComment("slug", "user", "new"); + case 1 -> service.editComment("slug", id, "user", "new"); + case 2 -> service.voteComment("slug", id, "user", true); + default -> service.setCommentPinned("slug", id, true); + } + }); + } + verify(projects, never()).evictProjectCache(any(Project.class)); + verifyNoInteractions(repository, notifications); + verify(users, never()).save(any(User.class)); + } + @Test void successfulCommentActionsUseCanonicalSnapshotAndPreserveBehavior() { + var snapshot = setup(); when(writes.applyComments(any())).thenReturn(true); + var comment = snapshot.project().getComments().getFirst(); + service.addComment("slug", "user", "new"); + assertEquals("new", snapshot.project().getComments().getFirst().getContent()); + service.editComment("slug", comment.getId(), "user", "edited"); assertEquals("edited", comment.getContent()); + service.voteComment("slug", comment.getId(), "user", true); assertTrue(comment.getUpvotes().contains("user")); + service.setCommentPinned("slug", comment.getId(), true); assertTrue(comment.isPinned()); + verify(writes, times(4)).applyComments(snapshot); + verify(projects, times(4)).evictProjectCache(snapshot.project()); verifyNoInteractions(repository); + } + @Test void favoriteFailureDoesNotEvictAndSuccessUsesCanonicalProjectId() { + var snapshot = setup(); when(repository.toggle("canonical", "user")).thenThrow(new IllegalStateException("transaction failed")); assertThrows(IllegalStateException.class, () -> service.toggleFavorite("slug", "user")); verify(projects, never()).evictProjectCache(any(Project.class)); verify(users, never()).save(any(User.class)); doReturn(1).when(repository).toggle("canonical", "user"); service.toggleFavorite("slug", "user"); - assertEquals(1, project.getFavoriteCount()); verify(projects).evictProjectCache(project); + assertEquals(1, snapshot.project().getFavoriteCount()); verify(projects).evictProjectCache(snapshot.project()); } } diff --git a/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java b/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java index c7f0f15ac..1e38504b7 100644 --- a/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java +++ b/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java @@ -3,9 +3,7 @@ import java.util.Optional; import net.modtale.exception.ForbiddenOperationException; import net.modtale.exception.ResourceNotFoundException; -import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; -import net.modtale.service.analytics.ScoringService; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; @@ -27,13 +25,13 @@ class SocialServiceTest { void setUp() { userRepository = mock(UserRepository.class); socialService = new SocialService( - mock(ProjectRepository.class), + mock(FavoritePersistence.class), userRepository, mock(ProjectService.class), mock(NotificationService.class), mock(SanitizationService.class), mock(MongoTemplate.class), - mock(ScoringService.class), mock(FavoritePersistence.class) + mock(net.modtale.service.admin.review.ProjectReviewPersistence.class) ); } diff --git a/backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java b/backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java new file mode 100644 index 000000000..f59834447 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java @@ -0,0 +1,29 @@ +package net.modtale.service.storage; + +import net.modtale.config.properties.AppR2Properties; +import net.modtale.exception.StorageDownloadException; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.core.ResponseInputStream; +import java.io.ByteArrayInputStream; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class BoundedStorageDownloadTest { + @Test void boundedDownloadChecksDeclaredAndActualLength()throws Exception { + var s3=mock(S3Client.class);var storage=new StorageService(s3,new AppR2Properties("bucket","","","",""),null); + var exact=spy(new ResponseInputStream<>(GetObjectResponse.builder().contentLength(3L).build(),new ByteArrayInputStream(new byte[]{1,2,3}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(exact);assertArrayEquals(new byte[]{1,2,3},storage.downloadBounded("key",3));verify(exact).close(); + var tooLarge=spy(new ResponseInputStream<>(GetObjectResponse.builder().contentLength(4L).build(),new ByteArrayInputStream(new byte[]{1,2,3,4}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(tooLarge);assertThrows(StorageDownloadException.class,()->storage.downloadBounded("key",3));verify(tooLarge).abort(); + var truncated=spy(new ResponseInputStream<>(GetObjectResponse.builder().contentLength(3L).build(),new ByteArrayInputStream(new byte[]{1}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(truncated);assertThrows(StorageDownloadException.class,()->storage.downloadBounded("key",3));verify(truncated).abort(); + } + @Test void missingLengthCannotBypassActualByteLimit() { + var s3=mock(S3Client.class);var storage=new StorageService(s3,new AppR2Properties("bucket","","","",""),null); + var response=spy(new ResponseInputStream<>(GetObjectResponse.builder().build(),new ByteArrayInputStream(new byte[]{1,2,3,4}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(response);assertThrows(StorageDownloadException.class,()->storage.downloadBounded("key",3));verify(response).abort(); + assertThrows(IllegalArgumentException.class,()->storage.downloadBounded("key",0)); + } +} diff --git a/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java b/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java index 5b058af00..7baf61957 100644 --- a/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java +++ b/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java @@ -15,7 +15,7 @@ import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.project.query.ProjectService; import net.modtale.service.project.version.ExternalDependencyArtifactService; import net.modtale.service.project.version.VersionDependencyService; @@ -56,7 +56,8 @@ void legacyCurseForgePackDataCanOnlyBePackagedAsAnAttributableReference() throws curseForge.setExternalFileStatus(4); curseForge.setExternalDistributionAllowed(false); - ProjectRepository projectRepository = mock(ProjectRepository.class); + ProjectReviewPersistence reviewPersistence = mock(ProjectReviewPersistence.class); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(true); DownloadArchiveSupport archiveSupport = mock(DownloadArchiveSupport.class); ProjectVersion packVersion = new ProjectVersion(); packVersion.setId("pack-version-1"); @@ -77,7 +78,7 @@ void legacyCurseForgePackDataCanOnlyBePackagedAsAnAttributableReference() throws .thenAnswer(invocation -> mock(MultipartFile.class)); when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/test-pack.zip"); - byte[] archive = new ModpackArchiveService(projectRepository, archiveSupport).generateModpackZip(pack, packVersion); + byte[] archive = new ModpackArchiveService(reviewPersistence, archiveSupport).generateModpackZip(pack, packVersion); Map entries = unzip(archive); JsonNode manifest = new ObjectMapper().readTree(entries.get("modpack.json")); JsonNode lock = new ObjectMapper().readTree(entries.get("modtale.lock.json")); diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java index b2821fbbe..1907fa1bf 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java @@ -19,7 +19,7 @@ import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,16 +43,17 @@ class DownloadServiceTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private DownloadService downloadService; - private ProjectRepository projectRepository; + private ProjectReviewPersistence reviewPersistence; private ProjectService projectService; private StorageService storageService; @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(ProjectReviewPersistence.class); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(true); projectService = mock(ProjectService.class); storageService = mock(StorageService.class); - downloadService = new DownloadService(projectRepository, projectService, storageService, limitProperties(10)); + downloadService = new DownloadService(reviewPersistence, projectService, storageService, limitProperties(10)); } @Test @@ -69,7 +70,7 @@ void generateModpackZipReturnsTheStoredArchiveWhenItAlreadyExists() throws Excep assertArrayEquals(cachedArchive, zipBytes); verify(storageService).download("modpacks/already-built.zip"); - verifyNoInteractions(projectService, projectRepository); + verifyNoInteractions(projectService, reviewPersistence); } @Test @@ -110,12 +111,12 @@ void generateModpackZipBuildsAndUploadsANewArchiveWhenTheCachedFileIsMissing() t ArgumentCaptor uploadCaptor = ArgumentCaptor.forClass(MultipartFile.class); verify(storageService).upload(uploadCaptor.capture(), eq("modpacks")); assertEquals("sky-pack-1.0.0.zip", uploadCaptor.getValue().getOriginalFilename()); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); } @Test void generateModpackZipAppliesPerUserRateLimiting() throws Exception { - downloadService = new DownloadService(projectRepository, projectService, storageService, limitProperties(1)); + downloadService = new DownloadService(reviewPersistence, projectService, storageService, limitProperties(1)); Project pack = pack("pack-1", "tiny-pack", "Tiny Pack"); ProjectVersion version = version("1.0.0"); diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java index cef117f1d..4c199a530 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java @@ -17,7 +17,7 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.multipart.MultipartFile; @@ -36,15 +36,16 @@ class ModpackArchiveServiceTest { - private ProjectRepository projectRepository; + private ProjectReviewPersistence reviewPersistence; private DownloadArchiveSupport archiveSupport; private ModpackArchiveService service; @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(ProjectReviewPersistence.class); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(true); archiveSupport = mock(DownloadArchiveSupport.class); - service = new ModpackArchiveService(projectRepository, archiveSupport); + service = new ModpackArchiveService(reviewPersistence, archiveSupport); } @Test @@ -88,7 +89,7 @@ void generateModpackZipReturnsCachedArchiveWhenDownloadSucceeds() throws Excepti when(archiveSupport.download("modpacks/cached.zip")).thenReturn(cached); assertArrayEquals(cached, service.generateModpackZip(pack, version)); - verify(projectRepository, never()).save(pack); + verify(reviewPersistence, never()).cacheModpackArchive(any(), any(), any(), any(), any()); } @Test @@ -108,7 +109,7 @@ void generateModpackZipRebuildsLegacyArchivesThatPredateIntegrityLockfiles() thr assertTrue(entries.containsKey("manifest.json")); assertTrue(entries.containsKey("modtale.lock.json")); assertEquals("modpacks/rebuilt.zip", version.getFileUrl()); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); } @Test @@ -124,7 +125,7 @@ void generateModpackZipRebuildsAnEmptyCachedArchive() throws Exception { assertEquals(true, entries.containsKey("modpack.json")); assertEquals("modpacks/rebuilt.zip", version.getFileUrl()); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); } @Test @@ -336,7 +337,32 @@ void generateModpackZipRebuildsCachesAndWritesDependencyFilesAtArchiveRoot() thr assertTrue(entries.get("modpack.json").contains("\"externalId\" : \"1450386\"")); assertTrue(entries.get("modpack.json").contains("\"distribution\" : \"REFERENCE_ONLY\"")); assertTrue(entries.get("modpack.json").contains("https://www.curseforge.com/hytale/mods/external-mod/files/8227810")); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); + } + + @Test + void cacheConflictDoesNotAttachOrReturnGeneratedArchive() throws Exception { + Project pack = pack(); ProjectVersion version = version("1.0.0", null); + when(archiveSupport.newZipMultipartFile(any(), any())).thenReturn(mock(MultipartFile.class)); + when(archiveSupport.upload(any(), eq("modpacks"))).thenReturn("modpacks/new.zip"); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(false); + org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.generateModpackZip(pack, version)); + org.junit.jupiter.api.Assertions.assertNull(version.getFileUrl()); + } + + @Test + void rebuildingInvalidCacheUsesTokensFromBeforeTheLocalReferenceWasCleared() throws Exception { + Project pack = pack(); ProjectVersion version = version("1.0.0", "modpacks/old.zip"); + version.setId("v1"); pack.setVersions(List.of(version)); + String projectToken = net.modtale.service.admin.review.ProjectReviewSnapshot.token(pack); + String versionToken = net.modtale.service.admin.review.VersionReviewSnapshot.token(version); + when(archiveSupport.download("modpacks/old.zip")).thenReturn(new byte[0]); + when(archiveSupport.newZipMultipartFile(any(), any())).thenReturn(mock(MultipartFile.class)); + when(archiveSupport.upload(any(), eq("modpacks"))).thenReturn("modpacks/new.zip"); + service.generateModpackZip(pack, version); + verify(reviewPersistence).cacheModpackArchive(pack.getId(), projectToken, "v1", versionToken, "modpacks/new.zip"); + assertEquals("modpacks/new.zip", version.getFileUrl()); } private static Project pack() { diff --git a/frontend/src/modules/admin/api/adminClient.ts b/frontend/src/modules/admin/api/adminClient.ts index 94bb75c0e..c07fe0803 100644 --- a/frontend/src/modules/admin/api/adminClient.ts +++ b/frontend/src/modules/admin/api/adminClient.ts @@ -1,22 +1,32 @@ import { api } from '@/utils/api'; -import type { AdminVerificationQueueItem } from '@/types'; + +export interface InspectionWindow { + identity: string; content: string; format: string; start: number; end: number; totalCharacters: number; + firstLine: number; lineMatched: boolean; representationComplete: boolean; gaps: string[]; +} export const adminClient = { getProjectMeta: async (projectId: string) => (await api.get(`/projects/${projectId}/meta`)).data, - getStructure: async (projectId: string, version: string) => (await api.get(`/admin/projects/${projectId}/versions/${version}/structure`)).data, - getFileContent: async (projectId: string, version: string, path: string) => (await api.get(`/admin/projects/${projectId}/versions/${version}/file`, { params: { path } })).data, + verifyDependencyBytes: async (projectId: string, versionId: string, reviewToken: string, inventoryIdentity: string): Promise => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/version-ids/${encodeURIComponent(versionId)}/dependency-bytes`, { headers: { 'If-Match': reviewToken }, params: { inventoryIdentity } })).data, + getDependencyInspection: async (projectId: string, versionId: string, reviewToken: string): Promise => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/version-ids/${encodeURIComponent(versionId)}/dependencies`, { headers: { 'If-Match': reviewToken } })).data, + getArtifactChanges: async (projectId: string, version: string, reviewToken: string): Promise => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/changes`, { headers: { 'If-Match': reviewToken } })).data, + getStructure: async (projectId: string, version: string, reviewToken: string) => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/structure`, { headers: { 'If-Match': reviewToken } })).data, + getFileContent: async (projectId: string, version: string, path: string, reviewToken: string) => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/file`, { params: { path }, headers: { 'If-Match': reviewToken } })).data, + getFileWindow: async (projectId: string, version: string, path: string, reviewToken: string, offset = 0, identity?: string, sourceLine = 0): Promise => + (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/file-window`, + { params: { path, offset, characters: 32000, sourceLine, identity }, headers: { 'If-Match': reviewToken } })).data, scanVersion: async (projectId: string, versionId: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/scan`)).data, - publishProject: async (projectId: string) => (await api.post(`/admin/projects/${projectId}/publish`)).data, - rejectProject: async (projectId: string, reason: string) => (await api.post(`/admin/projects/${projectId}/reject`, { reason })).data, - approveVersion: async (projectId: string, versionId: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/approve`)).data, - rejectVersion: async (projectId: string, versionId: string, reason: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/reject`, { reason })).data, + publishProject: async (projectId: string, reviewToken?: string, versionId?: string) => (await api.post(`/admin/projects/${projectId}/publish`, null, { headers: { 'If-Match': reviewToken }, params: { versionId } })).data, + rejectProject: async (projectId: string, reason: string, reviewToken?: string) => (await api.post(`/admin/projects/${projectId}/reject`, { reason }, { headers: { 'If-Match': reviewToken } })).data, + approveVersion: async (projectId: string, versionId: string, reviewToken?: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/approve`, null, { headers: { 'If-Match': reviewToken } })).data, + rejectVersion: async (projectId: string, versionId: string, reason: string, reviewToken?: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/reject`, { reason }, { headers: { 'If-Match': reviewToken } })).data, getLogs: async (params: any) => (await api.get('/admin/logs', { params })).data, getPlatformAnalytics: async (range: string) => (await api.get(`/analytics/platform/full?range=${range}`)).data, searchProjects: async (params: any) => (await api.get('/admin/projects/search', { params })).data, getProjectById: async (id: string) => (await api.get(`/admin/projects/${id}`)).data, - updateProjectRaw: async (id: string, data: any) => (await api.put(`/admin/projects/${id}/raw`, data)).data, + updateProjectRaw: async (id: string, data: Record, token: string) => (await api.put(`/admin/projects/${id}/raw`, data, { headers: { 'If-Match': token } })).data, deleteProject: async (id: string, reason: string) => (await api.delete(`/admin/projects/${id}`, { params: { reason } })).data, hardDeleteProject: async (id: string, reason: string) => (await api.delete(`/admin/projects/${id}/hard`, { params: { reason } })).data, restoreProject: async (id: string, status: string) => (await api.post(`/admin/projects/${id}/restore`, null, { params: { status } })).data, @@ -41,7 +51,6 @@ export const adminClient = { getUserRaw: async (userId: string) => (await api.get(`/admin/users/${userId}/raw`)).data, updateUserRaw: async (userId: string, data: any) => (await api.put(`/admin/users/${userId}/raw`, data)).data, - getVerificationQueue: async (): Promise => (await api.get('/admin/verification/queue')).data, getReviewDetails: async (id: string) => (await api.get(`/admin/projects/${id}/review-details`)).data, getCurrentAdmin: async () => (await api.get('/user/me')).data, diff --git a/frontend/src/modules/admin/api/findingReviews.ts b/frontend/src/modules/admin/api/findingReviews.ts new file mode 100644 index 000000000..7df933e15 --- /dev/null +++ b/frontend/src/modules/admin/api/findingReviews.ts @@ -0,0 +1,36 @@ +import { api } from '@/utils/api'; + +export interface FindingDecision { + id: string; + actorId: string; + createdAt: number; + expiresAt: number; + disposition: 'ACCEPT' | 'REQUIRE_REVIEW' | 'REVOKE'; + rationale: string; + scope: string; + finding: { path: string; description: string; lineStart: number }; + revokedDecisionId: string | null; + supersedesDecisionId?: string | null; +} +export interface DecisionAssessment { state: string; explanation: string } +export interface FindingHistory { + events: FindingDecision[]; nextOffset: number | null; + assessments?: Record; assessedAt?: number; +} +const path = (project: string, version: string) => `/admin/projects/${encodeURIComponent(project)}/versions/${encodeURIComponent(version)}/finding-decisions`; +export const findingReviews = { + history: async (project: string, version: string, token: string, offset = 0): Promise => + (await api.get(path(project, version), { headers: { 'If-Match': token }, params: { offset } })).data, + record: async (project: string, version: string, token: string, issueIndex: number, disposition: 'ACCEPT' | 'REQUIRE_REVIEW', rationale: string): Promise => + (await api.post(path(project, version), { issueIndex, disposition, rationale }, { headers: { 'If-Match': token } })).data, + revoke: async (project: string, version: string, token: string, decision: string, rationale: string): Promise => + (await api.post(`${path(project, version)}/${encodeURIComponent(decision)}/revoke`, { rationale }, { headers: { 'If-Match': token } })).data, +}; + +export interface PriorFindingReasoning { + reviewToken: string; sourceVersionId: string; sourceVersion: string; assessedAt: number; + reviewReasons: string[]; decisions: FindingDecision[]; omitted: number; +} +export const loadPriorFindingReasoning = async (project: string, version: string, sourceVersionId: string, issueIndex: number, token: string): Promise => + (await api.get(`/admin/projects/${encodeURIComponent(project)}/versions/${encodeURIComponent(version)}/prior-finding-reasoning`, + { headers: { 'If-Match': token }, params: { sourceVersionId, issueIndex } })).data; diff --git a/frontend/src/modules/admin/api/moderationQueue.ts b/frontend/src/modules/admin/api/moderationQueue.ts new file mode 100644 index 000000000..f4aa61b88 --- /dev/null +++ b/frontend/src/modules/admin/api/moderationQueue.ts @@ -0,0 +1,40 @@ +import { api } from '@/utils/api'; +import type { AdminVerificationQueueItem } from '@/types'; +export type QueueFilter = 'ALL' | 'SECURITY' | 'OPERATIONS'; +export interface QueuePage { filter?: QueueFilter; items: AdminVerificationQueueItem[]; nextCursor: string | null; unavailableItems: number; order: 'PROJECT_VERSION'; } +const cursorValid = (value: unknown): value is string => typeof value === 'string' && value.length <= 800 && /^(1|2\.(SECURITY|OPERATIONS))\.[os]\.(0|[1-9][0-9]{0,7})\.[A-Za-z0-9_-]{1,684}$/.test(value); +const cursorMatchesFilter = (cursor: string, filter: QueueFilter) => filter === 'ALL' ? cursor.startsWith('1.') : cursor.startsWith(`2.${filter}.`); +export function validateQueuePage(value: unknown, filter: QueueFilter = 'ALL'): QueuePage { + const page = value as QueuePage; + if (!page || (page.filter ?? 'ALL') !== filter || !Array.isArray(page.items) || page.items.length > 25 || page.order !== 'PROJECT_VERSION' + || (page.nextCursor !== null && (!cursorValid(page.nextCursor) || !cursorMatchesFilter(page.nextCursor, filter))) || !Number.isInteger(page.unavailableItems) + || page.unavailableItems < 0 || page.unavailableItems + page.items.length > 25) throw new Error('Invalid moderation queue response.'); + const keys = new Set(); + for (const row of page.items) { + if (!row || typeof row.id !== 'string' || !row.id || (row.pendingVersion != null + && (typeof row.pendingVersion.id !== 'string' || !row.pendingVersion.id || row.pendingVersion.reviewStatus !== 'PENDING'))) throw new Error('Invalid moderation queue entry.'); + for (const [field, max] of [['title',256],['description',1024],['author',128],['imageUrl',2048],['classification',32],['status',32],['updatedAt',64]] as const) { + const text = row[field]; if (text != null && (typeof text !== 'string' || [...text].length > max)) throw new Error('Invalid moderation queue display data.'); + } + if (row.pendingVersion) { + const version = row.pendingVersion; + if (version.versionNumber != null && (typeof version.versionNumber !== 'string' || [...version.versionNumber].length > 128) + || version.changelog != null && (typeof version.changelog !== 'string' || [...version.changelog].length > 1024)) throw new Error('Invalid moderation queue version.'); + if (version.scan != null) { + for (const field of ['status','verdict','scanState'] as const) if (version.scan[field] != null && typeof version.scan[field] !== 'string') throw new Error('Invalid moderation queue status.'); + for (const field of ['riskScore','knownIssueCount','newIssueCount','escalatedIssueCount'] as const) if (!Number.isInteger(version.scan[field])) throw new Error('Invalid moderation queue findings.'); + } + } + const key = JSON.stringify([row.id, row.pendingVersion?.id]); + if (keys.has(key)) throw new Error('Duplicate moderation queue entry.'); + keys.add(key); + } + return page; +} +export async function getModerationQueuePage(cursor: string | null, signal: AbortSignal, filter: QueueFilter = 'ALL'): Promise { + if (cursor !== null && (!cursorValid(cursor) || !cursorMatchesFilter(cursor, filter))) throw new Error('Invalid moderation queue cursor.'); + const response = await api.get('/admin/verification/queue/page', { params: { cursor: cursor ?? undefined, limit: 25, filter }, signal }); + const page = validateQueuePage(response.data, filter); + if (cursor !== null && page.nextCursor === cursor) throw new Error('The moderation queue did not advance. Refresh the queue.'); + return page; +} diff --git a/frontend/src/modules/admin/api/reviewCancellation.ts b/frontend/src/modules/admin/api/reviewCancellation.ts new file mode 100644 index 000000000..a69849397 --- /dev/null +++ b/frontend/src/modules/admin/api/reviewCancellation.ts @@ -0,0 +1,134 @@ +import { api } from '@/utils/api'; + +export type CancellationPreview = { isolationId: string; projectIdType: 'STRING' | 'OBJECT_ID'; projectId: string; versionIndex: number; versionId: string; requestId: string; jobId: string; beforeSha256: string; targetSha256: string; artifactSha256: string }; +export type CancellationIntent = { isolationId: string; targetSha256: string; createdAt: number; expiresAt: number }; +export type RemoteStatus = { jobId: string; state: string; artifactRetained: boolean; createdAt: number; expiresAt: number; workState: string | null }; +export type CancellationObservation = { kind: 'REMOTE_STATUS' | 'NOT_FOUND' | 'CONTEXT_CONFLICT' | 'UNAVAILABLE' | 'UNKNOWN'; status: RemoteStatus | null; httpStatus: number | null }; +export type CancellationReceipt = { prepared: CancellationIntent; state: 'PREPARED' | 'EXECUTING' | 'DISPATCHING' | 'OBSERVED' | 'UNKNOWN'; observation: CancellationObservation | null; observedAt: number | null }; +export type CancellationCheck = { id: string; original: CancellationIntent }; +export type CheckReceipt = { id: string; original: CancellationIntent; state: 'RESERVED' | 'READING' | 'OBSERVED' | 'UNKNOWN'; observation: CancellationObservation | null; receivedAt: number | null }; +export type CancellationExecution = { state: CancellationReceipt['state'] | 'BUSY' | 'SHUTDOWN'; receipt: CancellationReceipt | null }; +export type SavedCancellation = { preview: CancellationPreview; original: CancellationIntent; checkId: string | null }; +const base = '/admin/verification/cancellations'; +const noReplay = (signal: AbortSignal) => ({ signal, skipCsrfRetry: true }); +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const digest = /^[0-9a-f]{64}$/; +const invalid = () => new Error('Invalid cancellation response.'); +function object(value: unknown, keys: string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(k => !Object.hasOwn(value, k))) throw invalid(); + return value as Record; +} +function text(value: unknown, pattern: RegExp): string { if (typeof value !== 'string' || !pattern.test(value)) throw invalid(); return value; } +function positive(value: unknown): number { if (!Number.isSafeInteger(value) || (value as number) <= 0) throw invalid(); return value as number; } +function same(a: CancellationIntent, b: CancellationIntent) { if (a.isolationId !== b.isolationId || a.targetSha256 !== b.targetSha256 || a.createdAt !== b.createdAt || a.expiresAt !== b.expiresAt) throw invalid(); } +export function validateCancellationPreview(value: unknown, isolationId: string): CancellationPreview { + const p = object(value, ['isolationId', 'projectIdType', 'projectId', 'versionIndex', 'versionId', 'requestId', 'jobId', 'beforeSha256', 'targetSha256', 'artifactSha256']); + if (text(p.isolationId, uuid) !== text(isolationId, uuid) || !['STRING', 'OBJECT_ID'].includes(p.projectIdType as string) + || typeof p.projectId !== 'string' || !p.projectId || [...p.projectId].length > 128 || /[\uD800-\uDFFF]/u.test(p.projectId) + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) + || !Number.isSafeInteger(p.versionIndex) || (p.versionIndex as number) < 0 || (p.versionIndex as number) > 16777216 + || typeof p.versionId !== 'string' || !p.versionId.trim() || p.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(p.versionId)) throw invalid(); + text(p.requestId, uuid); text(p.jobId, uuid); text(p.beforeSha256, digest); text(p.targetSha256, digest); text(p.artifactSha256, digest); + return { ...p } as CancellationPreview; +} +export function validateCancellationIntent(value: unknown, preview: CancellationPreview): CancellationIntent { + const p = object(value, ['isolationId', 'targetSha256', 'createdAt', 'expiresAt']); + if (text(p.isolationId, uuid) !== preview.isolationId || text(p.targetSha256, digest) !== preview.targetSha256) throw invalid(); + const createdAt = positive(p.createdAt), expiresAt = positive(p.expiresAt); + if (expiresAt <= createdAt || expiresAt - createdAt > 120000) throw invalid(); + return { isolationId: p.isolationId as string, targetSha256: p.targetSha256 as string, createdAt, expiresAt }; +} +function observation(value: unknown, jobId: string): CancellationObservation { + const o = object(value, ['kind', 'status', 'httpStatus']); + if (!['REMOTE_STATUS', 'NOT_FOUND', 'CONTEXT_CONFLICT', 'UNAVAILABLE', 'UNKNOWN'].includes(o.kind as string)) throw invalid(); + if (o.kind === 'REMOTE_STATUS') { + const s = object(o.status, ['jobId', 'state', 'artifactRetained', 'createdAt', 'expiresAt', 'workState']); + if (o.httpStatus !== null || text(s.jobId, uuid) !== jobId || !['QUEUED', 'RUNNING', 'COMPLETED', 'CANCELLED', 'EXPIRED', 'HELD', 'UPLOADING', 'AWAITING_UPLOAD'].includes(s.state as string) + || typeof s.artifactRetained !== 'boolean' || positive(s.expiresAt) <= positive(s.createdAt) + || (s.workState !== null && (typeof s.workState !== 'string' || s.workState.length > 128))) throw invalid(); + return { kind: 'REMOTE_STATUS', status: { ...s } as RemoteStatus, httpStatus: null }; + } + if (o.status !== null || (o.httpStatus !== null && (!Number.isInteger(o.httpStatus) || (o.httpStatus as number) < 100 || (o.httpStatus as number) > 599)) + || (o.kind === 'NOT_FOUND' && o.httpStatus !== 404) || (o.kind === 'CONTEXT_CONFLICT' && o.httpStatus !== 409) + || (o.kind === 'UNAVAILABLE' && ![401, 403, 429].includes(o.httpStatus as number))) throw invalid(); + return { ...o } as CancellationObservation; +} +function outcome(state: unknown, value: unknown, time: unknown, preview: CancellationPreview, pending: string[]) { + if (pending.includes(state as string)) { if (value !== null || time !== null) throw invalid(); return null; } + if (state !== 'OBSERVED' && state !== 'UNKNOWN') throw invalid(); + positive(time); const result = observation(value, preview.jobId); + if ((state === 'UNKNOWN') !== (result.kind === 'UNKNOWN')) throw invalid(); return result; +} +export function validateCancellationReceipt(value: unknown, preview: CancellationPreview, expected?: CancellationIntent): CancellationReceipt { + const r = object(value, ['prepared', 'state', 'observation', 'observedAt']); + const prepared = validateCancellationIntent(r.prepared, preview); if (expected) same(prepared, expected); + const observed = outcome(r.state, r.observation, r.observedAt, preview, ['PREPARED', 'EXECUTING', 'DISPATCHING']); + if (r.observedAt !== null && (r.observedAt as number) < prepared.createdAt) throw invalid(); + return { prepared, state: r.state as CancellationReceipt['state'], observation: observed, observedAt: r.observedAt as number | null }; +} +export function validateCheckReceipt(value: unknown, preview: CancellationPreview, expected: CancellationCheck): CheckReceipt { + const r = object(value, ['id', 'original', 'state', 'observation', 'receivedAt']); + if (text(r.id, uuid) !== text(expected.id, uuid)) throw invalid(); + const original = validateCancellationIntent(r.original, preview); same(original, expected.original); + const observed = outcome(r.state, r.observation, r.receivedAt, preview, ['RESERVED', 'READING']); + return { id: r.id as string, original, state: r.state as CheckReceipt['state'], observation: observed, receivedAt: r.receivedAt as number | null }; +} +export function restoreCancellation(raw: string): SavedCancellation { + if (raw.length > 8192) throw invalid(); const s = object(JSON.parse(raw), ['preview', 'original', 'checkId']); + const id = (s.preview as CancellationPreview)?.isolationId; + const preview = validateCancellationPreview(s.preview, id), original = validateCancellationIntent(s.original, preview); + if (s.checkId !== null) text(s.checkId, uuid); + return { preview, original, checkId: s.checkId as string | null }; +} +export function saveCancellation(storage: Pick, subject: string, value: SavedCancellation): void { + if (!subject.trim()) throw invalid(); const raw = JSON.stringify(restoreCancellation(JSON.stringify(value))); + const key = cancellationStorageKey(subject); storage.setItem(key, raw); if (storage.getItem(key) !== raw) throw new Error('Cancellation reference could not be saved.'); +} +export function cancellationStorageKey(subject: string) { return `review-cancellation:${encodeURIComponent(subject)}`; } +export async function cancellationAvailable(signal: AbortSignal): Promise { + try { const { data } = await api.get(`${base}/capabilities`, { signal }); if (object(data, ['action']).action !== 'CANCEL_ORIGINAL_REVIEW') throw invalid(); return true; } + catch (e) { if ((e as { response?: { status?: number } })?.response?.status === 404) return false; throw e; } +} +export async function previewCancellation(id: string, signal: AbortSignal) { text(id, uuid); return validateCancellationPreview((await api.get(`${base}/targets/${id}`, { signal })).data, id); } +export async function prepareCancellation(preview: CancellationPreview, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId); + return validateCancellationIntent((await api.post(`${base}/prepare`, { isolationId: p.isolationId }, noReplay(signal))).data, p); +} +export async function recoverCancellation(preview: CancellationPreview, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId); + return validateCancellationReceipt((await api.get(`${base}/operations/${p.isolationId}`, { signal })).data, p); +} +export async function cancellationReceipt(preview: CancellationPreview, original: CancellationIntent, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId), intent = validateCancellationIntent(original, p); + return validateCancellationReceipt((await api.post(`${base}/receipt`, intent, { signal })).data, p, intent); +} +export async function executeCancellation(preview: CancellationPreview, original: CancellationIntent, signal: AbortSignal): Promise { + const p = validateCancellationPreview(preview, preview.isolationId), intent = validateCancellationIntent(original, p); + const r = object((await api.post(`${base}/execute`, intent, noReplay(signal))).data, ['state', 'receipt']); + if (r.receipt === null) { if (!['BUSY', 'SHUTDOWN', 'UNKNOWN'].includes(r.state as string)) throw invalid(); return { state: r.state as CancellationExecution['state'], receipt: null }; } + const receipt = validateCancellationReceipt(r.receipt, p, intent); if (receipt.state !== r.state) throw invalid(); + return { state: receipt.state, receipt }; +} +async function checkRequest(path: string, preview: CancellationPreview, check: CancellationCheck, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId), request = { id: text(check.id, uuid), original: validateCancellationIntent(check.original, p) }; + return validateCheckReceipt((await api.post(`${base}/${path}`, request, noReplay(signal))).data, p, request); +} +export const checkCancellation = (preview: CancellationPreview, check: CancellationCheck, signal: AbortSignal) => checkRequest('checks', preview, check, signal); +export const cancellationCheckReceipt = (preview: CancellationPreview, check: CancellationCheck, signal: AbortSignal) => checkRequest('checks/receipt', preview, check, signal); + +export type ObservationHistory = { items: { id: string; recordedState: CheckReceipt['state'] }[]; nextCursor: string | null; order: 'OBSERVATION_ID' }; +export async function cancellationHistory(preview: CancellationPreview, original: CancellationIntent, cursor: string | null, signal: AbortSignal): Promise { + const p = validateCancellationPreview(preview, preview.isolationId), intent = validateCancellationIntent(original, p); + const prefix = `c1.${intent.isolationId}.`; + let after = ''; + if (cursor !== null) { if (!cursor.startsWith(prefix)) throw invalid(); after = text(cursor.slice(prefix.length), uuid); } + const r = object((await api.post(`${base}/checks/history`, { original: intent, cursor, limit: 10 }, noReplay(signal))).data, ['items', 'nextCursor', 'order']); + if (r.order !== 'OBSERVATION_ID' || !Array.isArray(r.items) || r.items.length > 10) throw invalid(); + const items = r.items.map(value => { + const item = object(value, ['id', 'recordedState']), id = text(item.id, uuid); + if (id <= after || !['RESERVED', 'READING', 'OBSERVED', 'UNKNOWN'].includes(item.recordedState as string)) throw invalid(); + after = id; return { id, recordedState: item.recordedState as CheckReceipt['state'] }; + }); + if (r.nextCursor !== null && (items.length !== 10 || r.nextCursor !== prefix + after)) throw invalid(); + return { items, nextCursor: r.nextCursor as string | null, order: 'OBSERVATION_ID' }; +} diff --git a/frontend/src/modules/admin/api/reviewDiagnostics.ts b/frontend/src/modules/admin/api/reviewDiagnostics.ts new file mode 100644 index 000000000..c4b1f1386 --- /dev/null +++ b/frontend/src/modules/admin/api/reviewDiagnostics.ts @@ -0,0 +1,57 @@ +import { api } from '@/utils/api'; +export const diagnosticReasons = { + INVALID_PROJECT_ID: 'Project identifier needs repair.', + INVALID_VERSION_ID: 'Version identifier needs repair.', + DUPLICATE_VERSION_ID: 'Multiple versions share this identifier. Resolve the ambiguity before taking action.', + INVALID_REQUEST: 'The stored review request or attempt is invalid.', + INVALID_ARTIFACT: 'The stored file path or file hash is invalid.', + INVALID_MANUAL_MODE: 'The stored rescan setting is invalid.', + INVALID_SCAN_STATE: 'The stored scan state is not recognized.', + MISSING_BINDING: 'The remote review reference is missing.', + INVALID_BINDING: 'The stored review reference cannot be read.', + BINDING_MISMATCH: 'The stored review reference does not match this version.', + INVALID_POLL: 'The stored polling state is invalid.', + POLL_WITHOUT_REMOTE_STATE: 'Polling state is attached to a version outside remote review.', +} as const; +export type DiagnosticReason = keyof typeof diagnosticReasons; +export interface DiagnosticItem { position: { projectIdType: 'STRING' | 'OBJECT_ID'; projectId: string; versionIndex: number }; versionId: string | null; reasons: DiagnosticReason[]; } +export interface DiagnosticPage { items: DiagnosticItem[]; nextCursor: string | null; examinedSlots: number; scope: 'PENDING_SCAN_STRUCTURE'; } +const boundedIndex = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 16777216; +export function validDiagnosticCursor(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 810) return false; + const match = /^d1\.([av])\.1\.([so])\.(0|[1-9][0-9]{0,7})\.([A-Za-z0-9_-]{1,684})$/.exec(value); + if (!match || !boundedIndex(Number(match[3])) || (match[1] === 'a' && match[3] !== '0')) return false; + try { + const raw = atob(match[4].replace(/-/g, '+').replace(/_/g, '/')); + const decoded = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Uint8Array.from(raw, c => c.charCodeAt(0))); + const canonical = btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return canonical === match[4] && (match[2] === 'o' ? /^[0-9a-f]{24}$/.test(decoded) : decoded.length > 0 && [...decoded].length <= 128); + } catch { return false; } +} +export function validateDiagnosticPage(value: unknown): DiagnosticPage { + const page = value as DiagnosticPage; + if (!page || page.scope !== 'PENDING_SCAN_STRUCTURE' || !Array.isArray(page.items) || page.items.length > 25 + || !Number.isInteger(page.examinedSlots) || page.examinedSlots < page.items.length || page.examinedSlots > 25 + || (page.nextCursor !== null && !validDiagnosticCursor(page.nextCursor))) throw new Error('Invalid review diagnostic response.'); + const keys = new Set(); + for (const item of page.items) { + const p = item?.position; + if (!p || !['STRING', 'OBJECT_ID'].includes(p.projectIdType) || typeof p.projectId !== 'string' || !p.projectId || [...p.projectId].length > 128 + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) || !boundedIndex(p.versionIndex) + || (item.versionId !== null && (typeof item.versionId !== 'string' || !item.versionId.trim() || item.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(item.versionId))) + || !Array.isArray(item.reasons) || item.reasons.length < 1 || item.reasons.length > 12 + || item.reasons.some(reason => typeof reason !== 'string' || !Object.hasOwn(diagnosticReasons, reason)) || new Set(item.reasons).size !== item.reasons.length) + throw new Error('Invalid review diagnostic entry.'); + const key = JSON.stringify([p.projectIdType, p.projectId, p.versionIndex]); + if (keys.has(key)) throw new Error('Duplicate review diagnostic position.'); + keys.add(key); + } + return page; +} +export async function getDiagnosticPage(cursor: string | null, signal: AbortSignal): Promise { + if (cursor !== null && !validDiagnosticCursor(cursor)) throw new Error('Invalid review diagnostic cursor.'); + const response = await api.get('/admin/verification/diagnostics/page', { params: { cursor: cursor ?? undefined, limit: 25 }, signal }); + const page = validateDiagnosticPage(response.data); + if (cursor !== null && page.nextCursor === cursor) throw new Error('Diagnostics did not advance. Refresh from start.'); + return page; +} diff --git a/frontend/src/modules/admin/api/reviewOrigins.ts b/frontend/src/modules/admin/api/reviewOrigins.ts new file mode 100644 index 000000000..f54e3d558 --- /dev/null +++ b/frontend/src/modules/admin/api/reviewOrigins.ts @@ -0,0 +1,44 @@ +import { api } from '@/utils/api'; +import { validDiagnosticCursor } from './reviewDiagnostics'; +export const originLabels = { + MISSING: 'Original service not recorded', + INVALID: 'Original service record malformed', + RECORDED: 'Service identity recorded', + UNREADABLE_BINDING: 'Review reference unreadable', +} as const; +export interface OriginItem { + position: { projectIdType: 'STRING' | 'OBJECT_ID'; projectId: string; versionIndex: number }; + versionId: string | null; ambiguousVersion: boolean; requestId: string | null; jobId: string | null; + originState: keyof typeof originLabels; +} +export interface OriginPage { items: OriginItem[]; nextCursor: string | null; examinedSlots: number; scope: 'RETAINED_REVIEW_ORIGINS'; } +export function validOriginCursor(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('o1.') && validDiagnosticCursor(`d1.${value.slice(3)}`); +} +const uuid = (value: unknown) => value === null || typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value); +export function validateOriginPage(value: unknown): OriginPage { + const page = value as OriginPage; + if (!page || page.scope !== 'RETAINED_REVIEW_ORIGINS' || !Array.isArray(page.items) || page.items.length > 25 + || !Number.isInteger(page.examinedSlots) || page.examinedSlots < page.items.length || page.examinedSlots > 25 + || (page.nextCursor !== null && !validOriginCursor(page.nextCursor))) throw new Error('Invalid origin inventory response.'); + const seen = new Set(); + for (const item of page.items) { + const p = item?.position; + if (!p || !['STRING', 'OBJECT_ID'].includes(p.projectIdType) || typeof p.projectId !== 'string' || !p.projectId || [...p.projectId].length > 128 + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) || !Number.isInteger(p.versionIndex) || p.versionIndex < 0 || p.versionIndex > 16777216 + || (item.versionId !== null && (typeof item.versionId !== 'string' || !item.versionId.trim() || item.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(item.versionId))) + || typeof item.ambiguousVersion !== 'boolean' || (item.versionId === null && !item.ambiguousVersion) || !uuid(item.requestId) || !uuid(item.jobId) + || typeof item.originState !== 'string' || !Object.hasOwn(originLabels, item.originState)) throw new Error('Invalid origin inventory record.'); + const key = JSON.stringify([p.projectIdType, p.projectId, p.versionIndex]); + if (seen.has(key)) throw new Error('Duplicate origin inventory position.'); + seen.add(key); + } + return page; +} +export async function getOriginPage(cursor: string | null, signal: AbortSignal): Promise { + if (cursor !== null && !validOriginCursor(cursor)) throw new Error('Invalid origin inventory cursor.'); + const response = await api.get('/admin/verification/origins/page', { params: { cursor: cursor ?? undefined, limit: 25 }, signal }); + const page = validateOriginPage(response.data); + if (cursor !== null && cursor === page.nextCursor) throw new Error('Origin inventory did not advance. Refresh from start.'); + return page; +} diff --git a/frontend/src/modules/admin/api/reviewRepair.ts b/frontend/src/modules/admin/api/reviewRepair.ts new file mode 100644 index 000000000..365b10cfc --- /dev/null +++ b/frontend/src/modules/admin/api/reviewRepair.ts @@ -0,0 +1,102 @@ +import { api } from '@/utils/api'; +import type { DiagnosticItem } from './reviewDiagnostics'; +export type RepairTarget = { position: DiagnosticItem['position']; versionId: string }; +export type RepairPreview = RepairTarget & { sha256: string; eligible: boolean; effect: 'ISOLATE_LOCAL_REVIEW' }; +export type PreparedRepair = { id: string; sha256: string; createdAt: number; expiresAt: number }; +export type RepairResult = { state: 'APPLIED' | 'NOT_APPLIED' | 'INELIGIBLE' | 'UNKNOWN'; afterSha256: string | null }; +export type PendingRepair = { target: RepairTarget; prepared: PreparedRepair }; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const digest = /^[0-9a-f]{64}$/; +const invalid = () => new Error('Invalid repair response.'); +export function validateTarget(value: unknown): RepairTarget { + const t = value as RepairTarget; const p = t?.position; + if (!p || !['STRING', 'OBJECT_ID'].includes(p.projectIdType) || typeof p.projectId !== 'string' || !p.projectId || p.projectId.length > 128 + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) || !Number.isInteger(p.versionIndex) || p.versionIndex < 0 || p.versionIndex > 16777216 + || typeof t.versionId !== 'string' || !t.versionId.trim() || t.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(t.versionId)) throw invalid(); + return { position: { projectIdType: p.projectIdType, projectId: p.projectId, versionIndex: p.versionIndex }, versionId: t.versionId }; +} +export function validatePrepared(value: unknown): PreparedRepair { + const p = value as PreparedRepair; + if (!p || typeof p.id !== 'string' || !uuid.test(p.id) || typeof p.sha256 !== 'string' || !digest.test(p.sha256) + || !Number.isSafeInteger(p.createdAt) || p.createdAt <= 0 || !Number.isSafeInteger(p.expiresAt) || p.expiresAt <= p.createdAt || p.expiresAt - p.createdAt > 900000) throw invalid(); + return { id: p.id, sha256: p.sha256, createdAt: p.createdAt, expiresAt: p.expiresAt }; +} +export function validateResult(value: unknown): RepairResult { + const r = value as RepairResult; + if (!r || !['APPLIED', 'NOT_APPLIED', 'INELIGIBLE', 'UNKNOWN'].includes(r.state) + || (r.state === 'APPLIED' ? typeof r.afterSha256 !== 'string' || !digest.test(r.afterSha256) : r.afterSha256 !== null)) throw invalid(); + return { state: r.state, afterSha256: r.afterSha256 }; +} +export function restorePending(raw: string): PendingRepair { + if (raw.length > 4096) throw invalid(); + const parsed = JSON.parse(raw); + return { target: validateTarget(parsed?.target), prepared: validatePrepared(parsed?.prepared) }; +} +export async function repairAvailable(signal: AbortSignal): Promise { + try { + const response = await api.get('/admin/verification/repairs/capabilities', { signal }); + if (response.data?.action !== 'ISOLATE_LOCAL_REVIEW') throw invalid(); + return true; + } catch (error) { + if ((error as { response?: { status?: number } })?.response?.status === 404) return false; + throw error; + } +} +export async function inspectRepair(target: RepairTarget, signal: AbortSignal): Promise { + const expected = validateTarget(target); + const { data } = await api.post('/admin/verification/repairs/inspect', expected, { signal }); + const returned = validateTarget(data); + if (JSON.stringify(returned) !== JSON.stringify(expected) || typeof data.sha256 !== 'string' || !digest.test(data.sha256) + || typeof data.eligible !== 'boolean' || data.effect !== 'ISOLATE_LOCAL_REVIEW') throw invalid(); + return { ...returned, sha256: data.sha256, eligible: data.eligible, effect: data.effect }; +} +export async function prepareRepair(preview: RepairPreview, id: string, signal: AbortSignal): Promise { + if (!uuid.test(id) || !preview.eligible || !digest.test(preview.sha256)) throw invalid(); + const { data } = await api.post('/admin/verification/repairs/prepare', { ...validateTarget(preview), id, expectedSha256: preview.sha256 }, { signal }); + const prepared = validatePrepared(data); + if (prepared.id !== id || prepared.sha256 !== preview.sha256) throw invalid(); + return prepared; +} +export async function executeRepair(prepared: PreparedRepair, signal: AbortSignal): Promise { + const config = { signal, skipCsrfRetry: true }; + return validateResult((await api.post('/admin/verification/repairs/execute', validatePrepared(prepared), config)).data); +} +export async function repairReceipt(prepared: PreparedRepair, target: RepairTarget, signal: AbortSignal): Promise { + const expected = validateTarget(target); + const { data } = await api.post('/admin/verification/repairs/receipt', validatePrepared(prepared), { signal }); + if (JSON.stringify(validateTarget(data)) !== JSON.stringify(expected) || data.beforeSha256 !== prepared.sha256) throw invalid(); + return validateResult(data); +} + +export type RepairOperation = { id: string; recordedState: 'RESERVED' | 'EXECUTING' | 'UNKNOWN' | 'APPLIED' | 'NOT_APPLIED' }; +export type RepairOperationPage = { items: RepairOperation[]; nextCursor: string | null; order: 'OPERATION_ID' }; +export type RecoveredRepair = PendingRepair & { result: RepairResult }; +export async function repairOperations(cursor: string | null, signal: AbortSignal): Promise { + if (cursor !== null && !/^r1\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(cursor)) throw invalid(); + const { data } = await api.get('/admin/verification/repairs/operations', { params: { ...(cursor === null ? {} : { cursor }), limit: 25 }, signal }); + if (!data || data.order !== 'OPERATION_ID' || !Array.isArray(data.items) || data.items.length > 25) throw invalid(); + let previous = cursor?.slice(3) ?? ''; + const items: RepairOperation[] = data.items.map((item: RepairOperation) => { + if (!item || typeof item.id !== 'string' || !uuid.test(item.id) || item.id <= previous || !['RESERVED', 'EXECUTING', 'UNKNOWN', 'APPLIED', 'NOT_APPLIED'].includes(item.recordedState)) throw invalid(); + previous = item.id; + return { id: item.id, recordedState: item.recordedState }; + }); + if (data.nextCursor !== null && (items.length !== 25 || data.nextCursor !== `r1.${previous}`)) throw invalid(); + return { items, nextCursor: data.nextCursor, order: 'OPERATION_ID' }; +} +export async function recoverRepair(id: string, signal: AbortSignal): Promise { + if (!uuid.test(id)) throw invalid(); + const { data } = await api.get(`/admin/verification/repairs/operations/${id}`, { signal }); + const prepared = validatePrepared(data?.prepared); const target = validateTarget(data?.receipt); const result = validateResult(data?.receipt); + if (prepared.id !== id || data.receipt.beforeSha256 !== prepared.sha256 || result.state === 'INELIGIBLE') throw invalid(); + return { prepared, target, result }; +} + +export async function closeExpiredRepair(pending: PendingRepair, signal: AbortSignal): Promise { + const prepared = validatePrepared(pending.prepared); const target = validateTarget(pending.target); + const config = { signal, skipCsrfRetry: true }; + const { data } = await api.post('/admin/verification/repairs/close-expired', prepared, config); + if (JSON.stringify(validateTarget(data)) !== JSON.stringify(target) || data.beforeSha256 !== prepared.sha256) throw invalid(); + const result = validateResult(data); if (result.state === 'INELIGIBLE') throw invalid(); + return result; +} diff --git a/frontend/src/modules/admin/components/ProjectManagement.tsx b/frontend/src/modules/admin/components/ProjectManagement.tsx index 537c352c6..cd650707e 100644 --- a/frontend/src/modules/admin/components/ProjectManagement.tsx +++ b/frontend/src/modules/admin/components/ProjectManagement.tsx @@ -2,6 +2,7 @@ import { SkeletonSurface } from '@/components/ui/Skeleton'; import React, { useState, useEffect, useRef } from 'react'; import { Package, Search, Trash2, EyeOff, Clock, AlertTriangle, ArrowRight, Hash, Terminal, Download, RotateCcw, Code, X, FileJson, Lock } from 'lucide-react'; import { adminClient } from '../api/adminClient'; +import { projectMetadataForRepair } from '../utils/projectMetadataRepair'; import { API_BASE_URL, extractApiErrorMessage } from '@/utils/api'; import { AdminPermission, hasAdminPermission } from '../utils/access'; import type { Project, ScanIssue } from '@/types'; @@ -31,6 +32,7 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s const [showRawModal, setShowRawModal] = useState(false); const [rawJsonStr, setRawJsonStr] = useState(''); + const [rawReviewToken, setRawReviewToken] = useState(null); const [jsonError, setJsonError] = useState(null); useEffect(() => { @@ -138,7 +140,12 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s const openRawEdit = () => { if (!canEditRaw) return; - setRawJsonStr(JSON.stringify(foundProject, null, 2)); + if (!foundProject?.reviewToken) { + setStatus({ type: 'error', title: 'Refresh required', msg: 'Reload this project before editing its metadata.' }); + return; + } + setRawReviewToken(foundProject.reviewToken); + setRawJsonStr(JSON.stringify(projectMetadataForRepair(foundProject), null, 2)); setJsonError(null); setShowRawModal(true); }; @@ -178,14 +185,15 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s const saveRawEdit = async () => { if (!foundProject) return; - if (!canEditRaw) return; + if (!canEditRaw || !rawReviewToken) return; setLoading(true); try { const parsed = JSON.parse(rawJsonStr); - await adminClient.updateProjectRaw(foundProject.id, parsed); + await adminClient.updateProjectRaw(foundProject.id, parsed, rawReviewToken); setStatus({ type: 'success', title: 'Saved', msg: 'Raw project metadata updated successfully.' }); setShowRawModal(false); - setFoundProject(parsed); + setFoundProject(null); + setRawReviewToken(null); } catch (e: any) { setStatus({ type: 'error', @@ -275,7 +283,7 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s
{foundProject.status === 'DELETED' ? ( @@ -334,7 +342,7 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s
-

JSON Editor

Editing project: {foundProject.id}

+

Metadata Editor

Review and release changes use their dedicated actions. Project: {foundProject.id}

diff --git a/frontend/src/modules/admin/components/ReviewCancellationPanel.tsx b/frontend/src/modules/admin/components/ReviewCancellationPanel.tsx new file mode 100644 index 000000000..857bca53f --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewCancellationPanel.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from 'react'; +import { cancellationHistory, type ObservationHistory, cancellationAvailable, previewCancellation, prepareCancellation, executeCancellation, recoverCancellation, checkCancellation, cancellationCheckReceipt, restoreCancellation, saveCancellation, cancellationStorageKey, type CancellationPreview, type CancellationIntent, type CancellationReceipt, type CheckReceipt, type CancellationObservation } from '../api/reviewCancellation'; + +type Props = { subject: string; isolationId: string }; +export function ReviewCancellationPanel(props: Props) { return ; } +function Panel({ subject, isolationId }: Props) { + const owner = JSON.stringify([subject, isolationId]); + const [available, setAvailable] = useState(null); + const [preview, setPreview] = useState(null); + const [original, setOriginal] = useState(null); + const [receipt, setReceipt] = useState(null); + const [history, setHistory] = useState(null); + const [later, setLater] = useState([]); + const [checkId, setCheckId] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [corrupt, setCorrupt] = useState(false); + const [message, setMessage] = useState(''); + const [working, setWorking] = useState(false); + const [revision, setRevision] = useState(0); + const request = useRef(null); + const heading = useRef(null); + useEffect(() => { + const controller = new AbortController(); + cancellationAvailable(controller.signal).then(value => { if (!controller.signal.aborted) setAvailable(value); }).catch(() => { if (!controller.signal.aborted) setMessage('Cancellation availability could not be checked. Reopen this operation to retry.'); }); + try { + const raw = sessionStorage.getItem(cancellationStorageKey(owner)); + if (raw) { + const saved = restoreCancellation(raw); if (saved.original.isolationId !== isolationId) throw new Error('Mismatched reference'); + setPreview(saved.preview); setOriginal(saved.original); setCheckId(saved.checkId); + setMessage('A saved reference was found. Recover its original receipt before taking further action.'); + } + } catch { setCorrupt(true); setMessage('The saved cancellation reference could not be verified. Use original receipt recovery; no remote action is enabled.'); } + return () => { controller.abort(); request.current?.abort(); }; + }, [owner, isolationId]); + useEffect(() => { if (revision) heading.current?.focus(); }, [revision]); + async function run(task: (signal: AbortSignal) => Promise) { + if (request.current || available !== true) return; + const controller = new AbortController(); request.current = controller; setWorking(true); setMessage(''); + try { await task(controller.signal); } + catch { if (!controller.signal.aborted) { setConfirmed(false); setMessage('The response could not be verified. Recover the retained receipt; do not repeat the request.'); } } + finally { if (!controller.signal.aborted) { request.current = null; setWorking(false); setRevision(v => v + 1); } } + } + function persist(p: CancellationPreview, intent: CancellationIntent, id: string | null) { saveCancellation(sessionStorage, owner, { preview: p, original: intent, checkId: id }); } + function inspect() { + void run(async signal => { const value = await previewCancellation(isolationId, signal); if (!signal.aborted) { setPreview(value); setMessage('Review the original job before preparing cancellation.'); } }); + } + function prepare() { + if (!preview || original || corrupt) return; + void run(async signal => { + const value = await prepareCancellation(preview, signal); if (signal.aborted) return; + setOriginal(value); persist(preview, value, null); setConfirmed(true); + }); + } + function execute() { + if (!preview || !original || !confirmed || corrupt) return; + setConfirmed(false); + void run(async signal => { + persist(preview, original, checkId); + const value = await executeCancellation(preview, original, signal); if (signal.aborted) return; + setReceipt(value.receipt); setMessage(value.receipt ? '' : `Cancellation outcome: ${value.state}. Recover the original receipt before another action.`); + }); + } + function recover() { + setConfirmed(false); + void run(async signal => { + const fresh = await previewCancellation(isolationId, signal); if (signal.aborted) return; + const value = await recoverCancellation(fresh, signal); if (signal.aborted) return; + if (original && (value.prepared.createdAt !== original.createdAt || value.prepared.expiresAt !== original.expiresAt || value.prepared.targetSha256 !== original.targetSha256)) throw new Error('Changed intent'); + setPreview(fresh); setOriginal(value.prepared); setReceipt(value); + // A corrupt check reference cannot be silently replaced; original recovery remains read-only. + if (!corrupt) persist(fresh, value.prepared, checkId); + }); + } + function retainLater(value: CheckReceipt) { setLater(items => [...items.filter(item => item.id !== value.id), value].slice(-20)); } + function newCheck() { + if (!preview || !original || !receipt || receipt.state === 'PREPARED' || corrupt || checkId || later.length >= 20) return; + void run(async signal => { + const id = crypto.randomUUID(); setCheckId(id); persist(preview, original, id); + const value = await checkCancellation(preview, { id, original }, signal); if (!signal.aborted) retainLater(value); + }); + } + function recoverCheck() { + if (!preview || !original || !checkId || corrupt) return; + void run(async signal => { + const value = await cancellationCheckReceipt(preview, { id: checkId, original }, signal); if (!signal.aborted) retainLater(value); + }); + } + function loadHistory(cursor: string | null) { + if (!preview || !original || !receipt || receipt.state === 'PREPARED') return; + void run(async signal => { + const value = await cancellationHistory(preview, original, cursor, signal); + if (!signal.aborted) setHistory(value); + }); + } + function recoverHistory(id: string) { + if (!preview || !original) return; + void run(async signal => { + const value = await cancellationCheckReceipt(preview, { id, original }, signal); + if (!signal.aborted) retainLater(value); + }); + } + function finishCheck() { + if (!preview || !original || !checkId || !later.some(v => v.id === checkId && ['OBSERVED', 'UNKNOWN'].includes(v.state))) return; + try { persist(preview, original, null); setCheckId(null); setRevision(v => v + 1); } catch { setMessage('The reference could not be saved. Keep using receipt recovery.'); } + } + if (available === false) return null; + const disabled = working || available !== true; + return
+

Original remote review

+

Cancellation concerns the original remote job. It does not approve this mod, replace its review, or prove that work already in flight stopped.

+ {message &&

{message}

}{working &&

Checking original review…

} + {preview &&
+
Original project and version
{JSON.stringify(preview.projectId)} ({preview.projectIdType}), position {preview.versionIndex}, version {preview.versionId}
+
Original job
{preview.jobId}
Original request
{preview.requestId}
+
Artifact SHA-256
{preview.artifactSha256}
Target SHA-256
{preview.targetSha256}
+
} + {!preview && !corrupt && } + {preview && !original && !corrupt && } + {receipt?.state === 'PREPARED' && !confirmed && !corrupt && } + {confirmed &&

Confirm cancellation of the original job shown above. The server checks the signed target and original expiry.

} + + {receipt &&
Original cancellation outcome

{receipt.state}

{receipt.observedAt !== null &&

Receipt retained: {receipt.observedAt} milliseconds since Unix epoch

}
} + {receipt && receipt.state !== 'PREPARED' && !corrupt && !checkId && } + {checkId &&

Saved observation reference: {checkId}

+ {later.some(v => v.id === checkId && ['OBSERVED', 'UNKNOWN'].includes(v.state)) && }
} + {receipt && receipt.state !== 'PREPARED' &&
+ + {history &&

References are ordered by ID, not time. Refresh to find newly retained observations. Recover a receipt to verify its outcome.

+ {history.items.length === 0 &&

No retained observation references were found.

} +
    {history.items.map(item =>
  • {item.id}: recorded {item.recordedState}
  • )}
+ {history.nextCursor && } +
} +
} + {later.length > 0 &&
Later status observations

These separate responses do not replace the original outcome or prove which request caused a state change. Up to 20 verified receipts are shown in this view. Use retained observation references to recover other receipts.

+
    {later.map(item =>
  • {item.id}: {item.state}{item.receivedAt !== null &&

    Receipt retained: {item.receivedAt} milliseconds since Unix epoch

    }
  • )}
} +
; +} +function Observation({ value }: { value: CancellationObservation | null }) { + if (!value) return

No verified remote observation is retained in this receipt.

; + if (value.kind === 'REMOTE_STATUS') return

Remote job state: {value.status!.state}. Artifact retained: {value.status!.artifactRetained ? 'yes' : 'no'}.

; + return

Observation: {value.kind}{value.httpStatus === null ? '' : ` (HTTP ${value.httpStatus})`}. This does not establish that the job stopped.

; +} diff --git a/frontend/src/modules/admin/components/ReviewOrigins.tsx b/frontend/src/modules/admin/components/ReviewOrigins.tsx new file mode 100644 index 000000000..72b9d6fc7 --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewOrigins.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; +import { getOriginPage, originLabels, type OriginPage } from '../api/reviewOrigins'; + +export function ReviewOrigins({ subject }: { subject: string }) { return ; } +function OriginPanel() { + const [open, setOpen] = useState(false); + return
+ + {open && } +
; +} +function OriginContents() { + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const request = useRef(null); + const generation = useRef(0); + const attempted = useRef(null); + const heading = useRef(null); + const failure = useRef(null); + const [navigation, setNavigation] = useState({ revision: 0, failed: false }); + async function load(cursor: string | null, focus = true) { + request.current?.abort(); const controller = new AbortController(); request.current = controller; + const epoch = ++generation.current; attempted.current = cursor; setLoading(true); + try { + const result = await getOriginPage(cursor, controller.signal); + if (generation.current !== epoch) return; + setPage(result); setError(false); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: false })); + } catch { + if (generation.current !== epoch || controller.signal.aborted) return; + setError(true); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: true })); + } finally { if (generation.current === epoch) { request.current = null; setLoading(false); } } + } + useEffect(() => { void load(null, false); return () => { generation.current++; request.current?.abort(); }; }, []); + useEffect(() => { if (navigation.revision) (navigation.failed ? failure.current : heading.current)?.focus(); }, [navigation]); + return
+

Historical review origins

+

Inspect retained review references, including failed and completed versions. A recorded service identity does not establish that a mod is safe or that the original job has stopped. Missing origins need operator investigation; the current service cannot establish a historical origin.

+

This view does not change review state. Deleted versions and references retained only in repair archives are outside this inventory; use repair operation history for known isolation records.

+ {error &&
+

We could not load this origin page. Any entries below are from the last successful load.

+ +
} +
+ + + {loading ? 'Loading review origins…' : page ? `${page.items.length} retained references · ${page.examinedSlots} version slots examined on this page` : 'Origin inventory unavailable'} +
+ {page && !page.items.length &&

{page.nextCursor ? 'No retained references in these slots. Continue to the next page.' : 'No retained references on this page. Refresh from start to check for changes.'}

} + {page && page.items.length > 0 &&
    + {page.items.map(item =>
  • +

    {originLabels[item.originState]}

    +

    Project record: {JSON.stringify(item.position.projectId)} ({item.position.projectIdType === 'STRING' ? 'string ID' : 'ObjectId'})

    +

    Stored version position: {item.position.versionIndex} (zero-based)

    +

    Version: {item.versionId ?? 'Identifier unavailable'}

    + {item.ambiguousVersion &&

    Version identity is ambiguous. An operator must inspect the stored record before any action.

    } +

    Original request: {item.requestId ?? 'Unavailable'}

    +

    Original job: {item.jobId ?? 'Unavailable'}

    +
  • )} +
} +
; +} diff --git a/frontend/src/modules/admin/components/ReviewRepairHistory.tsx b/frontend/src/modules/admin/components/ReviewRepairHistory.tsx new file mode 100644 index 000000000..b75929e11 --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewRepairHistory.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { ReviewCancellationPanel } from './ReviewCancellationPanel'; +import { closeExpiredRepair, recoverRepair, repairOperations, type RecoveredRepair, type RepairOperationPage } from '../api/reviewRepair'; + +export function ReviewRepairHistory({ subject }: { subject?: string }) { + const [page, setPage] = useState(null); + const [receipt, setReceipt] = useState(null); + const [loading, setLoading] = useState(false); + const [confirmClose, setConfirmClose] = useState(false); + const [message, setMessage] = useState(''); + const [revision, setRevision] = useState(0); + const request = useRef(null); + const heading = useRef(null); + useEffect(() => () => { request.current?.abort(); }, []); + useEffect(() => { if (revision) heading.current?.focus(); }, [revision]); + async function run(task: (signal: AbortSignal) => Promise, accept: (value: T) => void) { + if (request.current) return; + const controller = new AbortController(); request.current = controller; setLoading(true); setMessage(''); setReceipt(null); setConfirmClose(false); + try { const value = await task(controller.signal); if (!controller.signal.aborted) accept(value); } + catch { if (!controller.signal.aborted) setMessage('The operation receipt could not be verified. The outcome remains unconfirmed; check the receipt again.'); } + finally { if (!controller.signal.aborted) { request.current = null; setLoading(false); setRevision(value => value + 1); } } + } + function load(cursor: string | null) { + if (request.current) return; + setPage(null); void run(signal => repairOperations(cursor, signal), setPage); + } + return
+

Your repair operation history

+

Find submitted operations even if their browser-tab reference was lost. References are ordered by ID, not date. Refresh to include new operations. Opening a reference only checks its historical receipt.

+ + {loading &&

Checking operation history…

} + {message &&

{message}

} + {page && <> + {page.items.length === 0 &&

No submitted operations found on this page.

} +
    {page.items.map(item =>
  • + + — Recorded state: {item.recordedState}. Verify the receipt for the outcome. +
  • )}
+ {page.nextCursor && } + } + {receipt &&
+

Operation reference: {receipt.prepared.id}

+

Original project {JSON.stringify(receipt.target.position.projectId)} ({receipt.target.position.projectIdType}), position {receipt.target.position.versionIndex}, version {receipt.target.versionId}.

+

Original record SHA-256: {receipt.prepared.sha256}

+ {receipt.result.state === 'UNKNOWN' &&
+

An expired attempt can be closed without repeating isolation. The server checks expiry; a committed isolation result is preserved. This does not approve the mod or cancel its remote job.

+ {!confirmClose ? : <> +

Confirm closing operation {receipt.prepared.id}. Closing a pending attempt prevents it from applying isolation. Any completed result stays unchanged. Refresh diagnostics before considering another action.

+ + + } +
} +

{receipt.result.state === 'APPLIED' ? 'The receipt confirms local isolation was applied. It does not establish the current version state or approve the mod.' : receipt.result.state === 'NOT_APPLIED' ? 'The receipt confirms isolation was not applied.' : 'The outcome remains unconfirmed. Do not repeat isolation. An expired attempt may be closed; otherwise check its receipt again.'}

+
} + {subject && receipt?.result.state === 'APPLIED' && } +
; +} diff --git a/frontend/src/modules/admin/components/ReviewRepairPanel.tsx b/frontend/src/modules/admin/components/ReviewRepairPanel.tsx new file mode 100644 index 000000000..2a11bf8fa --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewRepairPanel.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from 'react'; +import { ReviewRepairHistory } from './ReviewRepairHistory'; +import { executeRepair, inspectRepair, prepareRepair, repairAvailable, repairReceipt, restorePending, type PreparedRepair, type RepairPreview, type RepairResult, type RepairTarget } from '../api/reviewRepair'; + +type RepairPanelProps = { subject: string; selected: RepairTarget | null; onReady: (ready: boolean) => void; onLock: (locked: boolean) => void; onDismiss: () => void }; +export function ReviewRepairPanel(props: RepairPanelProps) { return ; } +function RepairPanel({ subject, selected, onReady, onLock, onDismiss }: RepairPanelProps) { + const [available, setAvailable] = useState(null); + const [availabilityError, setAvailabilityError] = useState(false); + const [target, setTarget] = useState(null); + const [preview, setPreview] = useState(null); + const [prepared, setPrepared] = useState(null); + const [result, setResult] = useState(null); + const [stage, setStage] = useState('idle'); + const [message, setMessage] = useState(''); + const request = useRef(null); + const epoch = useRef(0); const busy = useRef(false); const prepareId = useRef(''); + const heading = useRef(null); + const storageKey = `review-repair:${encodeURIComponent(subject)}`; + const terminal = result !== null && result.state !== 'UNKNOWN'; + const working = ['inspecting', 'preparing', 'executing', 'checking'].includes(stage); + const locked = working || stage === 'corrupt' || (prepared !== null && !terminal); + + useEffect(() => { onReady(available === true); }, [available, onReady]); + useEffect(() => { onLock(locked); }, [locked, onLock]); + useEffect(() => { + if (['preview', 'ready', 'result', 'error', 'corrupt'].includes(stage)) heading.current?.focus(); + }, [stage]); + useEffect(() => { + const controller = new AbortController(); let current = true; + repairAvailable(controller.signal).then(value => { if (current) setAvailable(value); }).catch(() => { if (current) setAvailabilityError(true); }); + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) { const pending = restorePending(raw); setTarget(pending.target); setPrepared(pending.prepared); setResult({ state: 'UNKNOWN', afterSha256: null }); setStage('result'); } + } catch { setStage('corrupt'); setMessage('The saved operation reference could not be read. Resolve it before starting another repair.'); } + return () => { current = false; controller.abort(); epoch.current++; request.current?.abort(); }; + }, [storageKey]); + async function run(task: (signal: AbortSignal) => Promise, accept: (value: T) => void, fail: () => void) { + if (busy.current) return; + busy.current = true; request.current?.abort(); const controller = new AbortController(); request.current = controller; const generation = ++epoch.current; + try { const value = await task(controller.signal); if (epoch.current === generation && !controller.signal.aborted) accept(value); } + catch { if (epoch.current === generation && !controller.signal.aborted) fail(); } + finally { if (epoch.current === generation) { busy.current = false; request.current = null; } } + } + useEffect(() => { + if (!selected || available !== true || locked) return; + setTarget(selected); setPreview(null); setPrepared(null); setResult(null); setMessage(''); prepareId.current = ''; setStage('inspecting'); + void run(signal => inspectRepair(selected, signal), value => { setPreview(value); setStage('preview'); }, () => { setMessage('The current record could not be inspected. Refresh diagnostics before trying again.'); setStage('error'); }); + // Selection is deliberate; availability and state changes must never restart an inspection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + function acceptResult(value: RepairResult) { + setResult(value); setStage('result'); setMessage(''); + if (value.state !== 'UNKNOWN') { try { sessionStorage.removeItem(storageKey); } catch { /* A retained reference remains safe to look up again. */ } } + } + function prepare() { + if (!preview?.eligible || busy.current) return; + prepareId.current ||= crypto.randomUUID(); setStage('preparing'); setMessage(''); + void run(signal => prepareRepair(preview, prepareId.current, signal), value => { setPrepared(value); setStage('ready'); }, () => { setMessage('Preparation could not be confirmed. No isolation was submitted. You can retry preparation using this inspection.'); setStage('preview'); }); + } + function execute() { + if (!prepared || !target || busy.current || stage !== 'ready') return; + try { + const saved = JSON.stringify({ target, prepared }); sessionStorage.setItem(storageKey, saved); + if (sessionStorage.getItem(storageKey) !== saved) throw new Error(); + } catch { setMessage('The operation reference could not be saved. Isolation has not started.'); return; } + setStage('executing'); setMessage(''); + void run(signal => executeRepair(prepared, signal), acceptResult, () => acceptResult({ state: 'UNKNOWN', afterSha256: null })); + } + function checkReceipt() { + if (!prepared || !target || busy.current || available !== true) return; + setStage('checking'); setMessage(''); + void run(signal => repairReceipt(prepared, target, signal), acceptResult, () => { setResult({ state: 'UNKNOWN', afterSha256: null }); setMessage('The receipt is unavailable. The outcome remains unconfirmed.'); setStage('result'); }); + } + function dismiss() { setTarget(null); setPreview(null); setPrepared(null); setResult(null); setStage('idle'); setMessage(''); onDismiss(); } + return
+ {availabilityError &&

Repair availability could not be checked. Reopen diagnostics to try again.

} + {available === false &&

Repair tools are not enabled on this server.

} + {(target || stage === 'corrupt') &&
+

Local review isolation

+ {target &&

Project {JSON.stringify(target.position.projectId)} ({target.position.projectIdType}), position {target.position.versionIndex}, version {target.versionId}

} +

Isolation moves this version to review service attention. The version remains pending, findings and blocking decisions are preserved, and the original record is archived. It does not approve the mod, cancel its remote job, or start a new scan.

+ {preview &&

Inspected record SHA-256: {preview.sha256}

} + {prepared && <>

Operation reference: {prepared.id}

Prepared until {new Date(prepared.expiresAt).toLocaleString()}.

} + {working &&

{stage === 'executing' ? 'Submitting isolation… Closing this view does not cancel a submitted operation.' : stage === 'checking' ? 'Checking the operation receipt…' : 'Inspecting or preparing the record…'}

} + {message &&

{message}

} + {stage === 'preview' && (preview?.eligible ? : preview &&

This record is not eligible for local isolation. An operator must resolve its remaining state.

)} + {stage === 'ready' && <>

Confirm isolation of the exact inspected record. If it has changed, the server will refuse the change.

} + {result && stage === 'result' &&
+ {result.state === 'APPLIED' ? 'Local review isolated. Refresh diagnostics and the review queue to see its current state.' : result.state === 'UNKNOWN' ? 'The outcome is unconfirmed. Check the receipt; do not submit another isolation for this record. This operation reference is saved in this browser tab.' : 'Isolation was not applied. Refresh diagnostics and inspect the current record.'} +
} + {result?.state === 'UNKNOWN' && } + {!working && stage !== 'corrupt' && result?.state !== 'UNKNOWN' && } +
} + {available === true && } +
; +} diff --git a/frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx b/frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx new file mode 100644 index 000000000..6e552e862 --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx @@ -0,0 +1,78 @@ +import { ReviewRepairPanel } from './ReviewRepairPanel'; +import type { RepairTarget } from '../api/reviewRepair'; +import { useEffect, useRef, useState } from 'react'; +import { diagnosticReasons, getDiagnosticPage, type DiagnosticPage } from '../api/reviewDiagnostics'; + +export function ReviewStateDiagnostics({ subject, canRepair = false }: { subject: string; canRepair?: boolean }) { + return ; +} +function DiagnosticPanel({ subject, canRepair }: { subject: string; canRepair: boolean }) { + const [open, setOpen] = useState(false); + return
+ + {open && } +
; +} +function DiagnosticContents({ subject, canRepair }: { subject: string; canRepair: boolean }) { + const [selected, setSelected] = useState(null); + const [repairAvailable, setRepairAvailable] = useState(false); + const [repairLocked, setRepairLocked] = useState(false); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const request = useRef(null); + const generation = useRef(0); + const attempted = useRef(null); + const heading = useRef(null); + const failure = useRef(null); + const [navigation, setNavigation] = useState({ revision: 0, failed: false }); + async function load(cursor: string | null, focus = true) { + request.current?.abort(); const controller = new AbortController(); request.current = controller; + const epoch = ++generation.current; attempted.current = cursor; setLoading(true); + try { + const result = await getDiagnosticPage(cursor, controller.signal); + if (generation.current !== epoch) return; + setPage(result); setError(false); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: false })); + } catch { + if (generation.current !== epoch || controller.signal.aborted) return; + setError(true); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: true })); + } finally { + if (generation.current === epoch) { request.current = null; setLoading(false); } + } + } + useEffect(() => { + void load(null, false); + return () => { generation.current++; request.current?.abort(); request.current = null; }; + }, []); + useEffect(() => { + if (navigation.revision) (navigation.failed ? failure.current : heading.current)?.focus(); + }, [navigation]); + return
+

Scan state diagnostics

+

Find stored scan records that need operator attention. These checks examine review state, not whether a mod is safe. Record positions can change; an operator must recheck current state before repairing it.

+ {canRepair && setSelected(null)} />} + {error &&
+

We could not load this diagnostic page. Any entries below are from the last successful load.

+ +
} +
+ + + {loading ? 'Loading diagnostics…' : page ? `${page.items.length} diagnostic entries · ${page.examinedSlots} version slots examined on this page` : 'Diagnostics unavailable'} +
+ {page && !page.items.length &&

{page.nextCursor ? 'No structural problems found in these slots. Continue to the next page.' : 'No diagnostic entries on this page. Refresh from start to check for changes.'}

} + {page && page.items.length > 0 &&
    + {page.items.map(item =>
  • +

    Project record: {JSON.stringify(item.position.projectId)} ({item.position.projectIdType === 'STRING' ? 'string ID' : 'ObjectId'})

    +

    Stored version position: {item.position.versionIndex} (zero-based)

    +

    Version: {item.versionId ?? 'Identifier unavailable'}

    + {canRepair && repairAvailable && } +
      {item.reasons.map(reason =>
    • {diagnosticReasons[reason]}
    • )}
    +
  • )} +
} +
; +} diff --git a/frontend/src/modules/admin/components/VerificationQueue.tsx b/frontend/src/modules/admin/components/VerificationQueue.tsx index ea617d655..64ba5381c 100644 --- a/frontend/src/modules/admin/components/VerificationQueue.tsx +++ b/frontend/src/modules/admin/components/VerificationQueue.tsx @@ -8,14 +8,17 @@ interface VerificationQueueProps { loadingQueue: boolean; loadFailed?: boolean; loadingReview: boolean; + hasMore?: boolean; + unavailableItems?: number; + loaded?: boolean; reviewingId?: string; - onReview: (id: string) => void; + onReview: (id: string, versionId?: string) => void; } export const VerificationQueue: React.FC = ({ - pendingProjects, loadingQueue, loadFailed, loadingReview, reviewingId, onReview + pendingProjects, loadingQueue, loadFailed, loadingReview, reviewingId, onReview, hasMore = false, unavailableItems = 0, loaded = true }) => { - const Surface = loadingQueue ? SkeletonSurface : React.Fragment; + const Surface = loadingQueue && pendingProjects.length === 0 ? SkeletonSurface : React.Fragment; if (loadingQueue && pendingProjects.length === 0) { pendingProjects = Array.from({ length: 3 }, (_, index) => ({ id: `pending-${index}`, title: 'Project review title', author: 'Creator name', @@ -28,14 +31,17 @@ export const VerificationQueue: React.FC = ({ return null; } + if (pendingProjects.length === 0 && (hasMore || unavailableItems > 0 || !loaded)) { + return

{hasMore ? 'No openable entries on this page. Continue to the next page.' : unavailableItems > 0 ? 'Some entries require data repair before they can be reviewed.' : 'Queue has not loaded yet.'}

; + } if (pendingProjects.length === 0) { return (
-

All Caught Up!

-

No projects or versions pending verification.

+

No entries on this page

+

Refresh from the start to check for new or changed reviews.

); } @@ -44,7 +50,8 @@ export const VerificationQueue: React.FC = ({ const isProjectPending = mod.status === 'PENDING'; const targetVersion = mod.pendingVersion; const scan = targetVersion?.scan; - const hasIssues = scan && scan.status !== 'CLEAN'; + const needsService = scan?.status === 'FAILED'; + const hasIssues = scan && scan.status !== 'CLEAN' && !needsService; const newIssues = scan?.newIssueCount || 0; const knownIssues = scan?.knownIssueCount || 0; const escalatedIssues = scan?.escalatedIssueCount || 0; @@ -52,7 +59,7 @@ export const VerificationQueue: React.FC = ({ const risk = scan?.riskScore || 0; return ( -
+
e.currentTarget.src = '/assets/favicon.svg'} /> {isProjectPending && ( @@ -77,6 +84,7 @@ export const VerificationQueue: React.FC = ({
+ {isProjectPending && targetVersion &&

Version {targetVersion.versionNumber}

} {isProjectPending ? (

{mod.description}

) : ( @@ -91,13 +99,17 @@ export const VerificationQueue: React.FC = ({
+ {needsService &&
+ {scan?.scanState === 'REMOTE_ORIGIN_UNVERIFIED' ? 'Original review service unverified' : scan?.scanState === 'REMOTE_CONTEXT_CONFLICT' ? 'Review service context conflict' : scan?.scanState === 'REMOTE_ISOLATED' ? 'Local review isolated' : ['REMOTE_BINDING_MISSING', 'REMOTE_BINDING_MISMATCH'].includes(scan?.scanState || '') ? 'Review state needs repair' : scan?.scanState === 'REMOTE_UNSUPPORTED_CONTEXT' ? 'Review context unsupported' : scan?.scanState === 'REMOTE_EXPIRED' ? 'Review expired' : scan?.scanState === 'REMOTE_CANCELLED' ? 'Review cancelled' : scan?.scanState === 'REMOTE_HELD' ? 'Review held' : 'Review unavailable'} + Security clearance withheld +
} {scan?.status === 'SCANNING' && (
@@ -126,7 +138,15 @@ export const VerificationQueue: React.FC = ({ return (
- {pendingProjects.map(renderQueueItem)} + {pendingProjects.some(mod => mod.pendingVersion?.scan?.status !== 'FAILED') &&
+

Content and security review

+ {pendingProjects.filter(mod => mod.pendingVersion?.scan?.status !== 'FAILED').map(renderQueueItem)} +
} + {pendingProjects.some(mod => mod.pendingVersion?.scan?.status === 'FAILED') &&
+

Review service attention

+

These security reviews did not complete. Inspect the failure before requesting another scan. Existing findings still require review.

+ {pendingProjects.filter(mod => mod.pendingVersion?.scan?.status === 'FAILED').map(renderQueueItem)} +
}
); }; diff --git a/frontend/src/modules/admin/hooks/useModerationQueue.ts b/frontend/src/modules/admin/hooks/useModerationQueue.ts new file mode 100644 index 000000000..3d74ccabd --- /dev/null +++ b/frontend/src/modules/admin/hooks/useModerationQueue.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getModerationQueuePage, type QueuePage, type QueueFilter } from '../api/moderationQueue'; +import { extractApiErrorMessage } from '@/utils/api'; +const empty: QueuePage = { items: [], nextCursor: null, unavailableItems: 0, order: 'PROJECT_VERSION' }; +export function useModerationQueue(enabled: boolean, subject: string, filter: QueueFilter = 'ALL') { + const [page, setPage] = useState(empty); + const [pageSubject, setPageSubject] = useState(subject); + const [pageFilter, setPageFilter] = useState(filter); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + const [navigation, setNavigation] = useState({ revision: 0, target: 'page' as 'page' | 'error' }); + const access = useRef({ enabled, subject, filter }); access.current = { enabled, subject, filter }; + const generation = useRef(0); + const request = useRef(null); + const cursor = useRef(null); + const nextCursor = useRef(null); + const attemptedCursor = useRef(null); + const failed = useRef(false); + const load = useCallback(async (position: string | null, background = false, focus = false) => { + if (!access.current.enabled || (background && (request.current || failed.current))) return; + request.current?.abort(); const controller = new AbortController(); request.current = controller; + const epoch = ++generation.current; const owner = access.current.subject; const selectedFilter = access.current.filter; + attemptedCursor.current = position; + setLoading(true); + const current = () => generation.current === epoch && access.current.enabled && access.current.subject === owner && access.current.filter === selectedFilter; + try { + const response = await getModerationQueuePage(position, controller.signal, selectedFilter); + if (!current()) return; + cursor.current = position; nextCursor.current = response.nextCursor; + setPage(response); setPageSubject(owner); setPageFilter(selectedFilter); failed.current = false; setError(null); setLoaded(true); + if (focus) setNavigation(previous => ({ revision: previous.revision + 1, target: 'page' })); + } catch (failure) { + if (current() && !controller.signal.aborted) { failed.current = true; setError(extractApiErrorMessage(failure, 'We could not load this queue page.')); + if (focus) setNavigation(previous => ({ revision: previous.revision + 1, target: 'error' })); + } + } finally { + if (generation.current === epoch) { request.current = null; setLoading(false); } + } + }, []); + useEffect(() => { + generation.current++; request.current?.abort(); request.current = null; + cursor.current = null; nextCursor.current = null; attemptedCursor.current = null; failed.current = false; + setPage(empty); setPageSubject(subject); setPageFilter(filter); setError(null); setLoaded(false); setLoading(false); setNavigation({ revision: 0, target: 'page' }); + if (enabled) void load(null); + return () => { generation.current++; request.current?.abort(); request.current = null; }; + }, [enabled, subject, filter, load]); + const refresh = useCallback((background = false) => load(cursor.current, background), [load]); + const restart = useCallback(() => load(null, false, true), [load]); + const next = useCallback(() => { if (!request.current && nextCursor.current) void load(nextCursor.current, false, true); }, [load]); + const retry = useCallback(() => load(attemptedCursor.current, false, true), [load]); + const visible = enabled && pageSubject === subject && pageFilter === filter; + return { page: visible ? page : empty, loading, error: visible ? error : null, loaded: visible && loaded, refresh, restart, next, retry, navigation }; +} diff --git a/frontend/src/modules/admin/utils/access.ts b/frontend/src/modules/admin/utils/access.ts index d4bb75113..7822f97ff 100644 --- a/frontend/src/modules/admin/utils/access.ts +++ b/frontend/src/modules/admin/utils/access.ts @@ -50,7 +50,7 @@ export const ADMIN_PERMISSION_GROUPS = [ { id: AdminPermission.PROJECT_DELETE, label: 'Delete Projects' }, { id: AdminPermission.PROJECT_RESTORE, label: 'Restore Projects' }, { id: AdminPermission.PROJECT_VERSION_DELETE, label: 'Delete Versions' }, - { id: AdminPermission.PROJECT_RAW_EDIT, label: 'Edit Raw Project JSON' } + { id: AdminPermission.PROJECT_RAW_EDIT, label: 'Repair Project Metadata' } ] }, { diff --git a/frontend/src/modules/admin/utils/projectMetadataRepair.ts b/frontend/src/modules/admin/utils/projectMetadataRepair.ts new file mode 100644 index 000000000..68c6a20c1 --- /dev/null +++ b/frontend/src/modules/admin/utils/projectMetadataRepair.ts @@ -0,0 +1,11 @@ +export const editableProjectMetadata = [ + 'title', 'about', 'description', 'imageUrl', 'bannerUrl', 'repositoryUrl', 'license', 'hmWikiSlug', + 'customLicenseOpenSource', 'allowModpacks', 'allowComments', 'hmWikiEnabled', 'galleryCarouselEnabled', + 'tags', 'galleryImages', 'links', 'galleryImageCaptions', +] as const; + +export function projectMetadataForRepair(project: Partial>): Record { + return Object.fromEntries(editableProjectMetadata + .filter(field => project[field] !== undefined && project[field] !== null) + .map(field => [field, project[field]])); +} diff --git a/frontend/src/modules/admin/views/AdminPanel.tsx b/frontend/src/modules/admin/views/AdminPanel.tsx index 3082284f5..6f7c9204d 100644 --- a/frontend/src/modules/admin/views/AdminPanel.tsx +++ b/frontend/src/modules/admin/views/AdminPanel.tsx @@ -1,3 +1,5 @@ +import { ReviewOrigins } from '../components/ReviewOrigins'; +import { ReviewStateDiagnostics } from '../components/ReviewStateDiagnostics'; import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Shield, Users, LayoutDashboard, ShieldAlert, Package, Activity, FileText, CalendarClock } from 'lucide-react'; import { adminClient } from '../api/adminClient'; @@ -12,7 +14,8 @@ import { PlatformAnalytics } from '../components/PlatformAnalytics'; import { AuditLogs } from '../components/AuditLogs'; import { StatusIncidents } from '../components/StatusIncidents'; import { AdminPermission, hasAdminPermission, hasAnyAdminPermission, isAdminUser } from '../utils/access'; -import type { AdminVerificationQueueItem } from '@/types'; +import { useModerationQueue } from '../hooks/useModerationQueue'; +import type { QueueFilter } from '../api/moderationQueue'; interface AdminPanelProps { currentUser: any; @@ -21,14 +24,10 @@ interface AdminPanelProps { type AdminTab = 'users' | 'verification' | 'reports' | 'projects' | 'analytics' | 'logs' | 'status'; export function AdminPanel({ currentUser }: AdminPanelProps) { + const [queueFilter, setQueueFilter] = useState('ALL'); const [activeTab, setActiveTab] = useState('verification'); const [status, setStatus] = useState(null); - const [pendingProjects, setPendingProjects] = useState([]); - const [loadingQueue, setLoadingQueue] = useState(false); - const [queueError, setQueueError] = useState(null); - const queueRequestInFlight = useRef(false); - const [reviewingProject, setReviewingProject] = useState(null); const [loadingReview, setLoadingReview] = useState(false); const [loadingReviewId, setLoadingReviewId] = useState(); @@ -83,16 +82,20 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { canUseProjectManagement, canUseUserManagement ]); + const queue = useModerationQueue(canReadReviewQueue, currentUser?.id || currentUser?.username || '', queueFilter); + const pendingProjects = queue.page.items; + const loadingQueue = queue.loading; + const queueError = queue.error; + const fetchQueue = queue.refresh; + const queueHeading = useRef(null); + const queueFailure = useRef(null); + useEffect(() => { + if (queue.navigation.revision === 0 || !canReadReviewQueue || activeTab !== 'verification' || reviewingProject) return; + (queue.navigation.target === 'error' ? queueFailure.current : queueHeading.current)?.focus(); + }, [queue.navigation, canReadReviewQueue, activeTab, reviewingProject]); const firstAllowedTab = (Object.keys(tabAccess) as AdminTab[]).find(tab => tabAccess[tab]); useEffect(() => { - if (canReadReviewQueue) { - fetchQueue(); - } else { - setPendingProjects([]); - setQueueError(null); - } - if (canReadReports) { fetchReports(); } else { @@ -120,23 +123,6 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { } }, [activeTab, firstAllowedTab, isAdmin, tabAccess]); - const fetchQueue = async (background = false) => { - if (!canReadReviewQueue || queueRequestInFlight.current) return; - queueRequestInFlight.current = true; - if (!background) setLoadingQueue(true); - try { - const data = await adminClient.getVerificationQueue(); - if (!Array.isArray(data)) throw new Error('The verification queue returned an invalid response.'); - setPendingProjects(data); - setQueueError(null); - } catch (e) { - setQueueError(extractApiErrorMessage(e, 'We could not load the verification queue.')); - } finally { - queueRequestInFlight.current = false; - if (!background) setLoadingQueue(false); - } - }; - const fetchReports = async () => { if (!canReadReports) return; setLoadingReports(true); @@ -151,13 +137,19 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { } }; - const fetchProjectDetails = async (id: string) => { + const fetchProjectDetails = async (id: string, versionId?: string) => { if (loadingReview) return; setLoadingReview(true); setLoadingReviewId(id); try { const data = await adminClient.getReviewDetails(id); - setReviewingProject(data); + if (versionId) { + const selected = data.mod.versions.filter((version: any) => version.id === versionId); + if (selected.length !== 1 || selected[0].reviewStatus !== 'PENDING' || selected[0].scanResult?.status === 'SCANNING') { + throw new Error('This version is no longer ready for review. Refresh the queue.'); + } + } + setReviewingProject({ ...data, selectedVersionId: versionId }); } catch (e) { setStatus({ type: 'error', title: 'Error', msg: extractApiErrorMessage(e, "We could not load this project's review details.") }); } finally { @@ -300,25 +292,42 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'verification' && canReadReviewQueue && (
-

Verification Queue

-

Review pending projects and updates.

+

Verification Queue

+

Review pending projects and updates. Pages are ordered by project and version, not risk.

{queueError && ( -
+
{queueError} -
)} +
+ + + + + {pendingProjects.length} entries on this page{loadingQueue ? ' · Loading…' : ''} +
+ {queue.page.unavailableItems > 0 &&

{queue.page.unavailableItems} entries on this page cannot be opened safely and need data repair. Continue to inspect other entries.

} + +
)} diff --git a/frontend/src/modules/admin/views/ArtifactChanges.tsx b/frontend/src/modules/admin/views/ArtifactChanges.tsx new file mode 100644 index 000000000..a4501043e --- /dev/null +++ b/frontend/src/modules/admin/views/ArtifactChanges.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { adminClient } from '../api/adminClient'; +import { extractApiErrorMessage } from '@/utils/api'; + +export interface ArtifactChangeSummary { + reviewToken: string; + baselineVersion: string | null; + contextComparable: boolean; + contextChanged: boolean; + added: number; + modified: number; + removed: number; + unchanged: number; + files: { path: string; change: 'ADDED' | 'MODIFIED' | 'REMOVED' | 'UNCHANGED' }[]; +} + +export function ArtifactChanges({ projectId, version, reviewToken, onInspect }: { + projectId: string; + version: string; + reviewToken: string; + onInspect: (version: string, path: string, reviewToken: string) => void; +}) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [showUnchanged, setShowUnchanged] = useState(false); + const [search, setSearch] = useState(''); + const [limit, setLimit] = useState(100); + const generation = useRef(0); + useEffect(() => { + generation.current++; + setResult(null); setLoading(false); setError(''); setSearch(''); setLimit(100); setShowUnchanged(false); + return () => { generation.current++; }; + }, [projectId, version, reviewToken]); + const load = async () => { + const request = ++generation.current; + setLoading(true); setError(''); setResult(null); + try { + const next = await adminClient.getArtifactChanges(projectId, version, reviewToken); + if (next.reviewToken !== reviewToken) throw new Error('This comparison no longer matches the opened review. Refresh its evidence.'); + if (request === generation.current) setResult(next); + } catch (failure) { + if (request === generation.current) setError(extractApiErrorMessage(failure, 'Artifact comparison is unavailable.')); + } finally { + if (request === generation.current) setLoading(false); + } + }; + const visible = useMemo(() => (result?.files || []).filter(file => + (showUnchanged || file.change !== 'UNCHANGED') && file.path.toLowerCase().includes(search.toLowerCase())), [result, showUnchanged, search]); + return
+
+

Changes since approval

+ +
+ {error &&

{error}

} + {result && !result.baselineVersion &&

No previously approved version is available for comparison.

} + {result?.baselineVersion && <> +

Compared with approved version {result.baselineVersion}, using the contents of both stored artifacts.

+
+ {result.added} added{result.modified} modified{result.removed} removed{result.unchanged} unchanged +
+ {(!result.contextComparable || result.contextChanged) &&

+ {result.contextChanged ? 'Version metadata changed and needs review.' : 'Separate files or dependency metadata need additional comparison.'} +

} +
+ { setSearch(event.target.value); setLimit(100); }} placeholder="Filter paths…" className="min-w-0 rounded-lg border border-slate-200 dark:border-slate-700 bg-transparent px-3 py-2 text-sm dark:text-white" /> + +
+
+ {visible.slice(0, limit).map(file => )} + {visible.length === 0 &&

No files match this view.

} +
+ {visible.length > limit && } + } +
; +} diff --git a/frontend/src/modules/admin/views/DependencyInspection.tsx b/frontend/src/modules/admin/views/DependencyInspection.tsx new file mode 100644 index 000000000..6f331e875 --- /dev/null +++ b/frontend/src/modules/admin/views/DependencyInspection.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { adminClient } from '../api/adminClient'; +import { extractApiErrorMessage } from '@/utils/api'; + +type Key = { projectId: string; versionId: string }; +export interface DependencyInspectionResult { + reviewToken: string; + artifactBytesVerified: boolean; + inventory: { + root: Key; + identity: string | null; + nodes: (Key & { versionNumber: string; artifactSha256: string })[]; + edges: { from: Key; declaration: number; dependency: { source: string; type: string; reference: { projectId: string; versionNumber: string } }; target: Key | null }[]; + gaps: { from: Key; reference: { projectId: string; versionNumber: string }; reason: string }[]; + }; +} +export interface DependencyByteResult { + reviewToken: string; + inventoryIdentity: string; + verification: { inventoryIdentity: string; state: string; bytes: number; artifacts: { fileReference: string; expectedSha256: string; actualSha256: string | null; bytes: number; state: string }[] }; +} +const byteStates: Record = { + MATCHED: 'Stored files matched the recorded hashes during this check. Security approval is still separate.', + MISMATCH: 'Stored file bytes do not match the recorded inventory. Review the mismatch before deciding.', + UNAVAILABLE: 'Stored file verification was unavailable.', BYTE_LIMIT: 'Verification reached its total byte limit. Some files remain unverified.', + TIME_LIMIT: 'Verification reached its time limit. File verification is incomplete.', BUSY: 'Verification capacity is occupied. Try again later.', + UNRESOLVED: 'Resolve the dependency inventory before verifying stored files.', +}; +const reasons: Record = { + MISSING: 'Pinned version not found', AMBIGUOUS: 'Version identity is ambiguous', UNAVAILABLE: 'Record unavailable', + EXTERNAL: 'External dependency requires inspection', CYCLE: 'Dependency cycle', DEPTH_LIMIT: 'Dependency depth limit reached', + NODE_LIMIT: 'Version limit reached', EDGE_LIMIT: 'Declaration limit reached', READ_LIMIT: 'Lookup limit reached', + CHANGED: 'Record changed during inspection', INVALID_IDENTITY: 'Artifact identity mismatch', + UNRESOLVED_CONTEXT: 'Runtime context is unresolved', SUPPLEMENTAL_CONTENT: 'Separate content requires inspection', +}; +export function DependencyInspection({ projectId, versionId, reviewToken }: { projectId: string; versionId: string; reviewToken: string }) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [bytes, setBytes] = useState(null); + const generation = useRef(0); + useEffect(() => { + generation.current++; setResult(null); setBytes(null); setLoading(false); setError(''); + return () => { generation.current++; }; + }, [projectId, versionId, reviewToken]); + const load = async () => { + const request = ++generation.current; setResult(null); setBytes(null); setError(''); setLoading(true); + try { + const next = await adminClient.getDependencyInspection(projectId, versionId, reviewToken); + if (next.reviewToken !== reviewToken || next.inventory.root.projectId !== projectId || next.inventory.root.versionId !== versionId) + throw new Error('Dependency inspection no longer matches this review. Refresh its evidence.'); + if (request === generation.current) setResult(next); + } catch (failure) { + if (request === generation.current) setError(extractApiErrorMessage(failure, 'Dependency inspection is unavailable.')); + } finally { if (request === generation.current) setLoading(false); } + }; + const verify = async () => { + if (!result?.inventory.identity) return; + const identity = result.inventory.identity; + const request = ++generation.current; setBytes(null); setError(''); setLoading(true); + try { + const next = await adminClient.verifyDependencyBytes(projectId, versionId, reviewToken, identity); + if (next.reviewToken !== reviewToken || next.inventoryIdentity !== identity || next.verification.inventoryIdentity !== identity) + throw new Error('Byte verification no longer matches this inventory. Inspect dependencies again.'); + if (request === generation.current) setBytes(next); + } catch (failure) { + if (request === generation.current) { setResult(null); setError(extractApiErrorMessage(failure, 'Stored file verification is unavailable.')); } + } finally { if (request === generation.current) setLoading(false); } + }; + return
+
+

Dependency inventory

+ +
+

Shows declared versions and recorded artifact hashes. Content verification and security approval are separate.

+ {error &&

{error}

} + {result && <> +

{result.inventory.nodes.length} versions recorded · {result.inventory.edges.length} declarations · {result.inventory.gaps.length} unresolved items

+ {result.inventory.identity && result.inventory.gaps.length === 0 && } + {bytes &&
+

{byteStates[bytes.verification.state] || 'File verification is incomplete.'}

+ {bytes.verification.artifacts.length > 0 &&
+ File observations ({bytes.verification.artifacts.length}) +
    {bytes.verification.artifacts.map((artifact, index) => +
  • {artifact.fileReference}: {artifact.state.toLowerCase()}
    Recorded: {artifact.expectedSha256}
    Observed: {artifact.actualSha256 || 'Unavailable'}
  • )}
+
} +
} + {result.inventory.gaps.length > 0 &&
    + {result.inventory.gaps.map((gap, index) =>
  • {gap.reference.projectId} / {gap.reference.versionNumber}: {reasons[gap.reason] || 'Unresolved dependency evidence'}
  • )} +
} +
+ + + + {result.inventory.nodes.map(node => + + )} +
Recorded versions, including the uploaded version
ProjectVersionRecorded SHA-256
{node.projectId}{node.versionNumber}{node.artifactSha256}
+
+
Dependency declarations ({result.inventory.edges.length}) +
    + {result.inventory.edges.map((edge, index) =>
  • {edge.from.projectId} / {edge.from.versionId} → {edge.dependency.reference.projectId} / {edge.dependency.reference.versionNumber} ({edge.dependency.type.toLowerCase()}, {edge.dependency.source.toLowerCase()})
  • )} +
+
+ } +
; +} diff --git a/frontend/src/modules/admin/views/FindingDecisions.tsx b/frontend/src/modules/admin/views/FindingDecisions.tsx new file mode 100644 index 000000000..7702e3336 --- /dev/null +++ b/frontend/src/modules/admin/views/FindingDecisions.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ScanIssue } from '@/types'; +import { findingReviews, type FindingDecision, type DecisionAssessment } from '../api/findingReviews'; +import { extractApiErrorMessage } from '@/utils/api'; + +type Props = { projectId: string; versionId: string; token?: string; issues: ScanIssue[]; canDecide: boolean; onSaved: () => void }; +export function FindingDecisions(props: Props) { + return ; +} +function SnapshotFindingDecisions({ projectId, versionId, token, issues, canDecide, onSaved }: Props) { + const [open, setOpen] = useState(false); + const [events, setEvents] = useState([]); + const [assessments, setAssessments] = useState>({}); + const [assessedAt, setAssessedAt] = useState(); + const [showApplicable, setShowApplicable] = useState(false); + const [next, setNext] = useState(null); + const [issueIndex, setIssueIndex] = useState(0); + const [disposition, setDisposition] = useState<'ACCEPT' | 'REQUIRE_REVIEW'>('REQUIRE_REVIEW'); + const [rationale, setRationale] = useState(''); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(''); + const [now, setNow] = useState(Date.now); + const generation = useRef(0); + const inFlight = useRef(false); + useEffect(() => () => { generation.current++; }, []); + useEffect(() => { + const expirations = events.filter(event => event.disposition === 'ACCEPT' && event.expiresAt > now + && assessments[event.id]?.state === 'APPLICABLE').map(event => event.expiresAt); + if (!expirations.length) return; + const delay = Math.max(0, Math.min(2_147_483_647, Math.min(...expirations) - Date.now() + 1)); + const timer = setTimeout(() => setNow(Date.now()), delay); + return () => clearTimeout(timer); + }, [events, assessments, now]); + async function load(offset = 0) { + if (!token) { setError('Reopen this review to load its current evidence.'); return; } + if (inFlight.current) return; + inFlight.current = true; + const request = ++generation.current; + setBusy(true); setError(''); + try { + const result = await findingReviews.history(projectId, versionId, token, offset); + if (request !== generation.current) return; + setNow(Date.now()); + setEvents(old => offset ? [...old, ...result.events] : result.events); + setAssessments(result.assessments ?? {}); + setAssessedAt(result.assessedAt); setNext(result.nextOffset); setOpen(true); + } catch (e) { if (request === generation.current) setError(extractApiErrorMessage(e, 'Could not load decision history.')); } + finally { if (request === generation.current) { inFlight.current = false; setBusy(false); } } + } + async function save(revokedId?: string) { + if (!token || !canDecide || saved || rationale.trim().length < 10) return; + if (inFlight.current) return; + inFlight.current = true; + const request = ++generation.current; + setBusy(true); setError(''); + try { + const event = revokedId + ? await findingReviews.revoke(projectId, versionId, token, revokedId, rationale) + : await findingReviews.record(projectId, versionId, token, issueIndex, disposition, rationale); + if (request !== generation.current) return; + setEvents(old => [event, ...old]); setSaved(true); onSaved(); + } catch (e) { if (request === generation.current) setError(extractApiErrorMessage(e, 'Could not save the decision. Your explanation has been kept.')); } + finally { if (request === generation.current) { inFlight.current = false; setBusy(false); } } + } + const superseded = new Set(events.map(event => event.supersedesDecisionId).filter(Boolean)); + const revoked = new Set(events.map(event => event.revokedDecisionId).filter(Boolean)); + const retained = (event: FindingDecision) => event.disposition === 'ACCEPT' && !saved && !revoked.has(event.id) && !superseded.has(event.id) && assessments[event.id]?.state === 'APPLICABLE' && event.expiresAt > now; + const retainedCount = events.filter(retained).length; + const visible = events.filter(event => showApplicable || !retained(event)); + return
+ + {error &&

{error}

} + {saved &&

Decision saved. Refresh the review evidence before another decision or publication.

} + {open && <> +

These decisions retain your reasoning for the inspected artifact and context. Recording or revoking a decision pauses automatic publication and requires a current moderator review. It does not grant security clearance.

+ {canDecide &&
+ {issues.length > 0 && <> + + + } +