[DT-3871] Expose typed study/dataset template validation and create valid drafts - #3028
[DT-3871] Expose typed study/dataset template validation and create valid drafts#3028kevinmarete wants to merge 24 commits into
Conversation
A U+0000 escape is valid UTF-8 and is not whitespace, so it survives a strict decode and reaches the draft document, where Postgres rejects it inside a jsonb value. Both draft writes bound a bare :json::jsonb, leaving the draft table as the only JSON write in this area without the guard the data access request statements have carried since the 2025 migration that stripped the escape out of data_access_request.data. Applies the same regexp_replace to insert and to the update that carries json. The update matters as much as the insert, since a draft is meant to be modified and saved. No read-side guard is needed: a stored row cannot hold the escape, because writing one would have failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
duos-ui must know a draft's document type before mapping it into the study form, and the generic read endpoint did not say: DraftStudyDataset holds its type in a private static field, which Gson skips, so meta carried everything about the draft except what kind of draft it is. Documenting the field alone would have described one that never shipped. Reads it from the draft itself rather than from whatever the concrete class serializes, so a later draft type reports itself for free and no client has to infer one from the UUID or the route that loaded it. The schema also gains name, which the response has always returned. The summary endpoint already carried its type and needed no change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chairpersons register studies, so they need the drafts that back one: the upload page admits them, and without this they would reach a 403 the moment a validated template produced a draft. The role set now matches the one the study registration endpoints already use. Role reach is not ownership. DraftServiceDAO still admits only a draft's creator or an admin, and is unchanged here; the new tests pin that a chairperson is refused another user's draft and gets their own. The endpoint test asserts the role set over every method carrying an HTTP verb rather than one method at a time, so an endpoint added later cannot be left out of it or left unguarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds POST /api/draft/v1/study-dataset/template-validation: one CSV in, either the errors to fix or a StudyDatasetSubmissionV1 draft holding the registration-shaped document. Typed rather than generic, so this validator does not become the implied one for whatever draft type comes next. A template that fails validation is a completed result, not a failed request: it answers 200 with valid false, which is what lets duos-ui render row and column context on the page instead of a toast. Only an unusable request fails -- no file part, more than one, or one over 5 MiB. The size limit moves to the resource because the plan, the contract, and the browser all treat it as a request failure, and the message is now shared with the service rather than written twice; the service keeps its own cap for any caller that reaches it directly. The two decisions the ticket left open are settled here. truncated reaches the wire, which duos-ui already renders as a notice. Oversize is a request failure while an empty, non-UTF-8, or malformed file stays a validation result, since only the latter is something the producer edits. Responses are written by JerseyGsonProvider rather than Jackson, so the wire shape is pinned by a test against the bytes: an absent row is how a client tells an unlocated error from one on row 1. A separate test reads the persisted document back with the registration endpoint's own mapper and runs its validator, so a draft cannot be structurally unusable there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every part of the upload path was unit tested and the seam between them was not: nothing showed one uploaded template becoming a row that the draft endpoints return. A database-backed test now runs the canonical fixture through validation and DraftService, then reads the draft back and asserts what duos-ui receives -- meta.draftType, a document that deserializes into StudyRegistrationRequest with no violations, and a name taken from the study -- with a second test proving another user cannot load it. Drops the uploaded filename check. The name is never stored, logged, or used, so the traversal check it performs protects nothing here, while it rejects any name outside ASCII: etudes.csv with its accent failed, which a producer could neither predict nor understand. The size cap it also applies was vacuous, since a multipart part usually reports its size as -1, and the bounded read is the real limit. Also asserts the created draft belongs to the caller, and shortens the comments added across this ticket to the ones that say something the code cannot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two contracts were asserted against stand-ins rather than the things they
are contracts with.
The persisted document was checked against the validator the registration
endpoint shares, not against the endpoint. It is now posted to a real
DatasetResource with the dataset services mocked the way DatasetResourceTest
mocks them, so deserialization and that endpoint's own validation are what
decide the test. Replacing the document with {} turns the 201 into a 400.
The OpenAPI examples were the only hand-written copy of the wire shape and
nothing read them. Both are now round-tripped through the response model, so
an example that outlives a renamed field fails rather than documenting an
endpoint that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rollout ticket asks for validation latency to be observable, which on this stack means @timed and the instrumented listener the application already registers; the study registration endpoint this one sits beside carries it for the same reason. duos-ui calls the URL literally, so a rename would compile, pass every test here, and 404 the page. The path is now asserted the way the role set is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sonar S5778: with draft.getUUID() inside the lambda, a failure in the setup call would read as the assertion passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
otchet-broad
left a comment
There was a problem hiding this comment.
Notes from Claude:
1. U+0000 strip corrupts escaped backslashes in JSON
src/main/java/org/broadinstitute/consent/http/db/DraftDAO.java:63 (and :81)
The regexp_replace U+0000 strip runs on raw JSON text and matches inside an escaped
backslash, silently corrupting legitimate values or producing invalid JSON.
In the Java text block, '\\\\u0000' resolves to the SQL literal '\\u0000', which Postgres
reads as literal backslash + u0000. That is the right pattern for a genuine U+0000
character (serialized with a single backslash), but not for a value that already contains the
six-character escape text (backslash, u, 0000) — Jackson/Gson serialize that with a
doubled backslash, and the pattern then matches starting at the second backslash:
{"studyName":"a\\u0000b"}becomes{"studyName":"a\b"}— the value silently turns into
a+ backspace, and the trailingbis eaten.- If the following character is not a legal JSON escape (e.g.
\\u0000x), the result
{"studyName":"a\x"}fails the::jsonbcast.DraftServiceDAO.insertDraftcatches it and
throwsBadRequestException, so the caller gets 400 "Drafts require valid json to be
submitted" for a template the server itself serialized.
updateDraftByDraftUUID (line 81) has the identical defect.
Fix: strip in Java after parsing, or match only an odd number of preceding backslashes —
not in the SQL string. Note the pattern is copied verbatim from DataAccessRequestDAO
(lines 369 / 428 / 454 / 481), so the same latent bug already ships there and a proper fix
touches both call sites.
2. Server-side draft-write failure surfaces as a 400
src/main/java/org/broadinstitute/consent/http/resources/StudyDatasetTemplateResource.java:63
A server-side draft-write failure is returned to the client as 400, contradicting both the
OpenAPI 400 description and the resource's own javadoc that only an unusable request fails.
DraftServiceDAO.insertDraft wraps every write failure in
BadRequestException("Error submitting draft. Drafts require valid json to be submitted.")
before a SQLException can escape, so createExceptionResponse maps it to 400. Combined with
finding 1, a valid template yields HTTP 400 blaming the caller's JSON — while
draftStudyDatasetTemplateValidation.yaml documents 400 as "No file part was supplied, or
more than one was", giving the client no way to tell the two apart.
The spec also documents no 500 at all, even though StudyDatasetTemplateResourceTest asserts
one, so the stubbed-SQLException test exercises a path production mostly cannot reach.
3. Spec-drift test passes vacuously on a missing pointer
src/test/java/org/broadinstitute/consent/http/models/dto/registration/template/TemplateValidationResponseExamplesTest.java:34
The spec-drift test passes vacuously when the JSON pointer misses — exactly the case it exists
to catch.
JsonNode.at() returns MissingNode, not null, so assertNotNull(documented) can never
fire. From there: MissingNode.toString() is ""; GsonUtil.fromJson("", …) returns null;
toJson(null) returns "null"; and JsonParser.parseString("") and parseString("null")
both return JsonNull.INSTANCE — so the final assertEquals compares JsonNull to JsonNull
and passes.
Rename the spec file, move the endpoint under a different response code, or rename the
valid/invalid example keys, and this test still goes green while documenting an endpoint
that no longer exists.
Fix: assert documented.isMissingNode() is false, or use readTree(...).requiredAt(...).
4. IOException subclasses miss the exact-class DISPATCH and return 500
src/main/java/org/broadinstitute/consent/http/resources/StudyDatasetTemplateResource.java:79
An I/O failure while reading the uploaded part returns 500, not the 400 the code comments and
spec promise, because the exception DISPATCH is keyed on exact class.
Resource.createExceptionResponse does DISPATCH.get(e.getClass()) — an exact-class lookup
with no superclass walk. IOException.class is registered (400), but content.readNBytes() on
a truncated or aborted upload throws a subclass (EOFException, SocketTimeoutException, or
a Jersey MappableException), which misses every key and falls through to the generic 500
branch. A client that drops the connection mid-upload gets a 500 and a Sentry event instead of
the documented 4xx.
5. Creation branch returns 200 instead of 201
src/main/java/org/broadinstitute/consent/http/resources/StudyDatasetTemplateResource.java:61
docs/API_GUIDELINES.md states "201 Created: successful creation." The valid branch
persists a new draft row and hands back its UUID, but responds Response.ok() (200).
draftStudyDatasetTemplateValidation.yaml documents only 200, so the contract bakes the
deviation in. The guidelines file is itself edited in this PR (413 added), so the rule was in
view.
6. The 5 MiB limit is enforced twice, with divergent outcomes
src/main/java/org/broadinstitute/consent/http/service/studytemplate/StudyTemplateValidationService.java:118
The resource reads at most MAX_TEMPLATE_BYTES + 1 bytes and returns 413 when over, then hands
the service a ByteArrayInputStream of at most MAX_TEMPLATE_BYTES — so readTemplate's own
bytes.length > MAX_TEMPLATE_BYTES guard and its TOO_LARGE_MESSAGE error can never fire
through the endpoint. The same condition means 413-with-Error from one layer and
200-with-validation-error from the other; only the message string is shared, not the behavior.
Fix: one owner for the limit — either the service returns a distinguishable too-large
result, or the resource stops duplicating the check.
7. Upload is buffered twice, doubling peak memory
src/main/java/org/broadinstitute/consent/http/resources/StudyDatasetTemplateResource.java:79
readTemplate calls readNBytes(MAX + 1) into a byte[] (up to ~5 MB), wraps it in a
ByteArrayInputStream, and StudyTemplateValidationService.readTemplate immediately calls
readNBytes(MAX + 1) again, allocating a second full copy, before decoding to a UTF-16 String
(up to ~10 MB) — on top of whatever Jersey already buffered for the part. Peak is ~20 MB per
in-flight request where ~15 MB would do.
Fix: pass the part's InputStream straight through, letting the service own the limit
(per finding 6). That removes one full copy.
8. Jackson @JsonInclude on a type only ever serialized with Gson
src/main/java/org/broadinstitute/consent/http/models/dto/registration/template/TemplateValidationResponse.java:11
@JsonInclude(NON_NULL) is dead code here that looks load-bearing. ConsentApplication:150
registers JerseyGsonProvider for all JSON entities, and the type's own tests assert against
GsonUtil. The "absent field means no value" contract described in the class javadoc actually
rests on Gson's default null-skipping, not on this annotation. A future change to
buildGsonNullSerializer() for this path would start emitting "draft": null while the
annotation still sits there implying it cannot.
Fix: drop the annotation and the Jackson import, or state the real mechanism in the javadoc.
9. Third independent new ObjectMapper() leaves the round-trip guarantee unenforced
src/main/java/org/broadinstitute/consent/http/service/studytemplate/StudyDatasetTemplateService.java:24
The comment's guarantee that the draft is serialized with the mapper the registration endpoint
reads is not enforced by construction. DatasetResource:78 has its own
private final ObjectMapper objectMapper = new ObjectMapper(), and the new service creates
another (as do two of the new tests). They agree today only because both are bare and
StudyRegistrationRequest carries its own @JsonInclude(NON_NULL) / @JsonIgnoreProperties.
Configure a module or a FAIL_ON_* feature on one and the draft written here silently stops
round-tripping — the exact failure StudyDatasetTemplateRegistrationContractTest exists to
prevent, but which it cannot catch since it builds both mappers itself.
Fix: inject one shared, configured mapper.
10. Duplicated role assertion with a drifting HTTP-method filter
src/test/java/org/broadinstitute/consent/http/resources/StudyDatasetTemplateResourceTest.java:156
The reflection-based role assertion is copy-pasted between two resource tests.
DraftResourceTest:127 filters over Set.of(GET, POST, PUT, PATCH, DELETE) while this copy
filters only POST, so an endpoint added to StudyDatasetTemplateResource with any other verb
slips through the very check the test was written to make exhaustive.
Fix: extract one shared helper (package-private in the resources test package) taking the
resource class and the expected role set.
11. No test at exactly MAX_TEMPLATE_BYTES
src/test/java/org/broadinstitute/consent/http/resources/StudyDatasetTemplateResourceTest.java:109
The boundary the read-one-byte-extra trick exists to get right is untested — only
MAX_TEMPLATE_BYTES + 1 is asserted (413). Flip the resource's > to >=, or change
readNBytes(MAX + 1) to readNBytes(MAX), and a legal 5 MiB template starts being rejected
with 413 (or an oversized one is silently truncated to exactly 5 MiB and validated as if
complete) with the whole suite still green.
12. List.copyOf(errors) NPEs on a null error list
src/main/java/org/broadinstitute/consent/http/models/dto/registration/template/TemplateValidationResponse.java:19
TemplateValidationResponse.invalid(null, false) — or Gson record deserialization of a
response body that omits errors, which the ExamplesTest path would hit if an example were
written without that key — calls List.copyOf(null) and NPEs with no message, surfacing as a
bare 500 rather than pointing at the missing field.
Fix: errors == null ? List.of() : List.copyOf(errors).
The two copies of the reflective role assertion had already drifted: the one on the template resource filtered POST alone, so an endpoint added there with any other verb would slip past the check written to be exhaustive. One helper now owns the verb set, and both tests call it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pattern matched a literal backslash followed by u0000, which is right for the escape a serialized U+0000 produces but also matches the tail of an already-escaped backslash. A value holding that text as characters reaches Postgres with the backslash doubled, the strip starts at the second one, and the six characters it removes take the following one with them. Where that leaves an escape the parser rejects, the ::jsonb cast fails and DraftServiceDAO reports a 400 blaming the caller's JSON for a document the server serialized itself. A negative lookbehind leaves the escaped pair alone. Without it the new test fails with Postgres's own "invalid input syntax for type json". The same pattern sits in DataAccessRequestDAO, where it has shipped since the 2025 migration and is left for its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The limit was enforced twice with different outcomes: the resource read one byte past it and answered 413, then handed the validator a stream that could no longer exceed it, so the validator's own check and its error could never fire through the endpoint. The two layers shared the message and nothing else. The validator owns it now and refuses an oversized file outright; the resource streams the part straight through and turns that refusal into the 413 the contract documents. One buffer instead of two, which is about 5 MB less per in-flight request, and the read that could fail mid-upload happens in the one place that already reports a read failure as a validation error rather than letting an IOException subclass fall through the exact-class dispatch to a 500. The boundary moves with the check: a file at exactly the limit is now asserted to be read and validated, which nothing pinned before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed the draft is written with the mapper the registration endpoint reads, and nothing held that up: this service built its own bare ObjectMapper, DatasetResource built another, and the contract test built two more of its own. They agree only because all four are unconfigured, so a module or a FAIL_ON_* feature added to either side would break the round trip the contract test exists to protect while it went on passing. One binding supplies both, and the test uses the single instance the injector would. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec-drift test could not fail on the case it was written for: a missing pointer yields a node that stringifies to nothing, deserializes to null, and serializes back to JSON null on both sides of the comparison. Renaming the spec file or moving the endpoint left it green. It reads the pointer as required now. @JsonInclude was dead: Gson writes every JSON entity here, and the absent-field contract rests on its default, not on the annotation. The javadoc says so rather than pointing at Jackson. A null error list NPEs where an empty one is meant, which a body omitting errors would hit. The documented statuses now include the 500 the endpoint can answer and the draft-write failure DraftServiceDAO reports as a 400, which the 400 text described as a missing file part. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The valid branch inserts a draft row and handed it back with 200, while DraftResource.createDraftRegistration answers 201 with a Location for the same row. API_GUIDELINES.md reserves 201 for creation, and this PR edits that file, so the deviation was one the next reviewer would raise again. The valid branch is now Response.created at the path the draft endpoints serve the draft from; an invalid template still answers 200, since nothing was created and the errors are the result. The spec splits along the same line, and the moved example keeps the response model pinned to it. duos-ui needs no change: fetchMultipart gates on res.ok, so the whole 2xx range reaches the caller, which discriminates on the body's valid field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The null guard added for review had no test, so JaCoCo reported half the branches on the record's constructor uncovered. Deserializing a body that omits errors is the case that reaches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the review — all twelve findings are triaged below, in seven commits on top of
The correction on #1. The text block does not resolve to a literal backslash plus U+0000. The On #5 and duos-ui. The 201 costs nothing on the client: Also left open.
@fboulnois — your 👍 was on |
Sonar S110: ClientErrorException put the exception six parents deep. The repo's other JAX-RS exceptions sit at the same depth and predate the leak period, so only this new one is measured, but the inheritance was buying nothing either way — the resource builds the 413 itself and reads only the message, so nothing consulted the status the base class carried. A plain RuntimeException also stops a validator that touches no HTTP from throwing a JAX-RS type, which is the separation the repo asks for. The exceptions package already holds three that are not JAX-RS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine comments from the review round said in two lines what one says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SQL regexp_replace could not tell a real escape from the literal text of one. Its lookbehind spared an escaped backslash, but only by looking at the one character before the match, so an odd backslash run of three or more slipped a live escape through to the jsonb cast that rejects it. NulCharacters measures the whole run instead, which is what decides whether the last backslash escapes the u0000 or is escaped text in its own right. The strip also never reached :name. The name is decoded from the same document, so a studyName carrying the escape arrived holding the character itself and failed the write outright with 'invalid byte sequence for encoding "UTF8": 0x00' — a text column will not hold a U+0000 either. Stripping in Java, before binding, covers both parameters and both writes. The three DraftDAOTest cases for the strip go with the SQL that performed it; DraftServiceDAOTest now covers the behaviour a layer up, where the name is in scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 5 MiB limit was reached only after the whole multipart entity had been read: a resource method taking a FormDataMultiPart parameter is not called until Jersey has spooled the body, so nothing bounded what a caller could push at the endpoint. The validator still owns the limit on the file; this owns the boundary, the point past which no more of the request is read. Two guards, because either alone leaves a way through. A Content-Length past the limit plus its multipart envelope is refused before a byte of the body is read, which is the honest oversize upload. A chunked body, or one under a header that lies, has no length to check, so the entity stream is bounded as well. What that second half promises is the bound, not the status — a body cut off mid-parse may surface as a failed read rather than the 413, since by then the status is the container's to decide. The header is parsed as a long rather than through getLength(), whose int parse turns the multi-gigabyte body this most needs to catch into an unknown length. TemplateTooLargeExceptionMapper gives the 413 one owner, used by the filter, by the resource, and by a refusal raised while Jersey is still reading. The endpoint test posts through Jersey because a name binding that fails to take effect fails silently, which no unit test on the filter can tell apart from one that works. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Location header repeated /api/draft/v1 as a literal, so a path change on DraftResource would leave this endpoint pointing at nothing while every test here kept passing. Asking UriBuilder for the class and method paths yields the same URI from the annotations that define it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
API_GUIDELINES.md gives 403 to a caller lacking permissions, and the endpoint is @RolesAllowed, so ForbiddenExceptionMapper answers an unpermitted role with 403 and an Error body the spec never mentioned. The 401 alongside it described that same authorization failure, which is the one thing 401 does not mean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
S2184: the request cap added two ints and widened the result, so the sum was computed in int arithmetic. Nothing overflows at 5 MiB plus 8 KiB, but the constant is a size and reads as one now that it is a long. S3776: stripFromJsonText carried the whole scan, at a cognitive complexity of 18. Measuring a backslash run is the part with the nesting, so it moves to a method that returns where the run left off. S1130 and S1612 in the test: two methods declared an IOException the lambda beneath them swallows, and two lambdas wrapped a method reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mapper's toResponse had no test at all: every assertion went through the static builder the resource calls, so the one path that answers a refusal raised while Jersey is still reading was the one path unproven. The bounded stream was only ever read through readAllBytes, leaving the single-byte read and both skip outcomes uncovered, and a Content-Length that is blank or unparsable never reached the branch that treats it as no length. The strip gained the run a document ends on, where the scan stops because the text ran out rather than because a non-backslash stopped it. The three files are at full line and branch coverage; a skip that returns normally is needed alongside the one that refuses, since a method that always exits by exception records no coverage past the throw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widening the constant left the multiplication itself in int, which is the half S2184 was pointing at; the previous commit moved the warning rather than clearing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Addresses
https://broadworkbench.atlassian.net/browse/DT-3871
Security risk: low — adds an authenticated endpoint that accepts a CSV and writes one draft the caller owns. Parsing was reviewed in DT-3870; this adds the request boundary around it:
Admin,Chairperson, andDataSubmitteronly, the same roles as study registration, with per-draft ownership unchanged inDraftServiceDAO. Uploads are bounded at 5 MiB as they are read, no uploaded content is logged, and the draft writes strip the U+0000 escape thatjsonbrejects. Chairpersons gain reach to the draft endpoints, not to anyone else's drafts — two tests pin that.Summary
Ticket 3 of the study-template workflow planned in duos-ui at
docs/plans/study-template-validation-workflow.md. AddsPOST /api/draft/v1/study-dataset/template-validation: one CSV in, either the errors to fix or aStudyDatasetSubmissionV1draft holding the registration-shaped document. Typed rather than generic, so this validator does not become the implied one for whatever draft type comes next. This unblocks DT-1854, which is built against this contract and cannot be released before it.An invalid template is a completed result; a valid one is a creation. Errors answer 200 with
valid: false, which is what lets duos-ui render row and column context on the page rather than as a toast. A valid template answers 201 with aLocationfor the draft it created, asDraftResource.createDraftRegistrationdoes for the same row. Only an unusable request fails: no file part, more than one, or one over 5 MiB. duos-ui needs no change for the 201 —fetchMultipartgates onres.ok, and callers discriminate on the body'svalid.The two decisions the ticket left open are settled here.
truncatedreaches the wire — duos-ui already renders a notice for it. Oversize is a request failure (413) while an empty, non-UTF-8, or malformed file stays a validation result, since only the latter is something the producer edits.docs/study-template-v1.md, the OpenAPI spec, and the plan in duos-ui now all describe that same boundary;docs/API_GUIDELINES.mdgains 413, which its status-code list did not have.Fifteen commits, each reviewable on its own. The first eight build the endpoint:
4375dbc6the U+0000 guard.jsonbrejects the escape, andDraftDAOwas the only JSON write in this area without theregexp_replacethe data access request statements have carried since the 2025 migration. Both the insert and the update path get it — a draft is meant to be edited and saved. No read-side guard is needed: a stored row cannot hold the escape, because writing one would have failed.ceb2642dmeta.draftType.DraftStudyDatasetholds its type in a private static field, which Gson skips, so the draft detail response carried everything about a draft except what kind it is. It is read from the draft rather than from whatever the class serializes, so a later draft type reports itself for free.d6f71d31chairpersons on the draft endpoints. The upload page admits them; without this they would hit a 403 the moment a validated template produced a draft.890c4e97the endpoint, with990ed50f,76eca217,7d23061d,9cb249f2closing the gaps found reviewing it: the seam between validation and the draft endpoints proven against a real database, the persisted document posted to the real registration endpoint, the OpenAPI examples read back through the response model, and the endpoint timed and its path pinned.The last seven answer review, and each is worth reading on its own:
c76bed24one verb set for the role scan. The two copies of the reflective role assertion had already drifted — the template resource filteredPOSTalone, so an endpoint added there with another verb would have slipped past the check written to be exhaustive.946aa12athe U+0000 strip spares an escaped backslash. The pattern matched a literal backslash followed byu0000, which is right for a serialized U+0000 but also matches the tail of an already-escaped backslash: the strip started at the second backslash and took the following character with it, and where that left an escape the parser rejects, the::jsonbcast failed andDraftServiceDAOreported a 400 blaming the caller's JSON for a document the server serialized itself. A negative lookbehind fixes it; without it the new test fails with Postgres's owninvalid input syntax for type json.66231888one owner for the size limit. It was enforced twice with different outcomes — the resource read a byte past it and answered 413, then handed the validator a stream that could no longer exceed it, so the validator's own check could never fire. The validator owns it now and the resource streams the part straight through, which drops a full second copy of the upload and moves the read that can fail mid-upload into the one place that already reports a read failure as a validation error.97b55684oneObjectMapper. The claim that the draft is written with the mapper the registration endpoint reads was held up by nothing: this service,DatasetResource, and the contract test each built their own. They agreed only because all were unconfigured. One binding supplies both sides, and the contract test uses the single instance.9b54566d,34a73346the response model and its spec test. The spec-drift test could not fail on a missing pointer, which is the case it exists to catch; it reads the pointer as required now.@JsonIncludewas dead — Gson writes every JSON entity here — and a null error list NPE'd where an empty one is meant.40d062eb201 at the created draft, perdocs/API_GUIDELINES.mdand the sibling endpoint that writes the same row.Two things review raised that are deliberately not fixed here.
DataAccessRequestDAOcarries the same U+0000 pattern in four statements, where it has shipped since the 2025 migration; fixing it is its own change against its own tests. AndDraftServiceDAO.insertDraftwraps every write failure inBadRequestException, so a server-side failure reads as a 400 — pre-existing behaviour on a shared path, now described in the spec's 400 text rather than changed underneath its other callers.Still worth a reviewer's eye:
validateFileDetails, which protects a stored name; this endpoint reads the bytes and drops the part, so there is no stored name for a traversal check to protect. It rejects any name outside ASCII —études.csvfails — which a producer could neither predict nor understand. Its size cap was vacuous too, since a multipart part usually reports its size as-1; the bounded read is the real limit.createExceptionResponseentry that would change behaviour for unrelated code.valid: false, not 400. The acceptance criterion grouped it with the request failures; it is file content, and "Template file is empty" belongs on the page with the other content errors. The AC has been updated to match.JerseyGsonProviderwrites every JSON entity. The wire shape is therefore pinned by a test against the bytes rather than by annotations, because an absentrowis how a client tells an unlocated error from one on row 1.Testing
Tests run: 267green across the twelve suites this touches, including two database-backed classes against a real Postgres container. JaCoCo reports 100% line and branch coverage on every new class.spotless:checkclean and the OpenAPI generator validation passes.The tests that would catch a regression rather than restate the code:
ERROR: unsupported Unicode escape sequence, and the new escaped-backslash test fails against the previous pattern withERROR: invalid input syntax for type json— the 400 the review predicted.StudyDatasetTemplateServiceDAOTestruns the canonical fixture through validation andDraftServiceagainst a real database, then asserts what duos-ui receives:meta.draftType, a document that deserializes intoStudyRegistrationRequestwith no violations, a name taken from the study, and a second user refused.StudyDatasetTemplateRegistrationContractTestposts the persisted document to a realDatasetResource.createDatasetRegistrationand asserts 201, both sides sharing the one mapper the injector supplies. Replacing the document with{}turns it into a 400.TemplateValidationResponseExamplesTestround-trips the 200 and 201 examples through the response model; renamingdraftTypein an example, or moving one to a status it no longer answers, fails it.Not covered, deliberately:
@RolesAllowedis asserted reflectively, as everywhere else in this repo — proving Jersey enforces it needs aResourceExtensionharness with an auth filter, which is worth doing once across all resources rather than for one endpoint.Have you read CONTRIBUTING.md lately? If not, do that first.