Configuration Import and Export
M5. FccTestConfigIoController → FccConfigExportService / FccConfigImportService, configio/FccConfigXmlWriter, configio/FccConfigXmlReader, configio/FccConfigRecordFields. See Endpoints Implemented in M5 for the two endpoints' request/response shape and status codes; this page is the document format and the reasoning a schema cannot carry.
What it is for: moving a campaign’s configuration between installations — the next deployment, a staging system, an operator who has to run the same compliance test in another domain. It is not a backup and not a report. The export writes one campaign; the import reads one and answers a FORM, creating nothing (The Import Parses and Returns, and Creates Nothing). Both directions are streamed with StAX, one <test> and one fleet page at a time, batched at 1000 devices in both directions, because a single campaign may carry up to ft.fcc.max-devices-per-test (20000 by default) and neither the writer nor the reader may hold the whole document in memory.
The Document
<?xml version="1.0" encoding="UTF-8"?>
<fccTests formatVersion="2" exportedAt="2026-09-24T10:15:00Z" generator="ft-fcc-service/0.0.1-SNAPSHOT">
<test>
<name>Downtown fiber compliance - Q3</name>
<domainId>17</domainId>
<reactivationDays>7</reactivationDays>
<activation>
<onlyOnlineDevices>true</onlyOnlineDevices>
<totalTestDuration>24</totalTestDuration>
<singleTestDuration>30</singleTestDuration>
<batchInterval>0</batchInterval>
<failedTestRetries>1</failedTestRetries>
<wanUsageRetries>3</wanUsageRetries>
</activation>
<configuration>
<type>SPEED</type>
<speedSetting>
<wanDownloadThreshold>5000</wanDownloadThreshold>
<wanUploadThreshold>1000</wanUploadThreshold>
<speedTierDownload>100000</speedTierDownload>
<speedTierUpload>20000</speedTierUpload>
<downloadUrl>http://speedtest.example.net/download</downloadUrl>
<uploadUrl>http://speedtest.example.net/upload</uploadUrl>
<downloadConnections>4</downloadConnections>
<uploadConnections>2</uploadConnections>
<fileSizeUpload>50000000</fileSizeUpload>
<testMode>FILE_BASED</testMode>
</speedSetting>
<devices>
<serial>ABC123456789</serial>
<serial>ABC123456790</serial>
</devices>
</configuration>
<configuration>
<type>LATENCY</type>
<latencySetting>
<testHost>ping.example.net</testHost>
<repetitions>10</repetitions>
</latencySetting>
<devices>
<serial>ABC123456789</serial>
</devices>
</configuration>
</test>
</fccTests>
The <test> element
| Element | Type | Required | Notes |
|---|---|---|---|
|
String |
Yes |
Max 255, the same bound as |
|
|
Yes |
Decides which settings block is required and which is refused — see [settings-match-type-here]. |
|
Integer |
Conditionally |
See Which Domain an Imported Campaign Lands In. Absent, present-and-visible, present-and-foreign and non-positive are four different outcomes. |
|
Integer, 0..9 |
No |
Absent means the column default ( |
|
Block, 11 fields |
No |
See |
|
Block, 12 fields |
Exactly one of |
|
|
Block, 8 fields |
Exactly one of |
|
|
Block of |
No |
Absent means an empty fleet — the normal case for moving a configuration between installations, since the target’s device inventory is not the source’s. See The Fleet Travels as Serials, and an Unresolved Serial Fails the Parse. |
The reader is a switch on element name, not a position check: the elements of <test> may appear in any order in a hand-edited document, though the writer always emits them in the order above. Each element may be declared at most once — a repeated <name> or a repeated field inside a block is refused ("<name> is declared more than once; a configuration document states every value exactly once"), which is what lets a reader trust that the values it collected are the whole story.
Every constraint of FccTestUpsertRequest is enforced on import, including <name>’s `@Size(max = 255), by running the assembled request through the programmatic jakarta.validation.Validator rather than by a hand-written re-check. Those constraints already apply on PUT /FCC/item/details, but bean validation runs at the HTTP boundary and PUT /FCC/item/import does not cross one, so without this the document was the one way into FccTestService.upsert that could skip them. As first shipped, two of the constraints were re-checked by hand — @NotBlank on <name> and @NotNull on <type> — and the set had already drifted: @Size(max = 255) beside them was not re-checked, so a document carrying a <name> longer than 255 characters reached fcc_test.name VARCHAR(255) and came back as an internal error instead of the 400 the length limit exists to produce. Violations are now rendered field message, joined and sorted by field, in the same shape FccExceptionHandler.handleBeanValidation uses for the HTTP boundary — which works cleanly here because the document’s element names are the request record’s field names (The Vocabulary Is Derived From the Request Records, Not Hand-Written). A constraint added to FccTestUpsertRequest in the future applies to the document with no change to this package.
<activation> — eleven fields
| Element | Type | Notes |
|---|---|---|
|
Boolean |
PLURAL, and the element moved WITH the record component rather than being aliased: the document’s
vocabulary is derived from |
|
Boolean |
|
|
Integer |
|
|
Integer |
|
|
Integer |
|
|
Integer, not Boolean |
A retry count, not a yes/no flag — this is the field an implementer guesses wrong most often, because the name reads like a switch. |
|
Integer, not Boolean |
Same guess, same answer. |
|
Integer |
|
|
Integer |
|
|
Integer |
Easy to miss reading the endpoint’s own summary, which is why it is called out here: it is the eleventh field, not a variant spelling of |
|
Integer |
The four *Sec fields fall through the same three-level settings chain as PUT /FCC/item/details when absent — see The Settings Chain, and How to Opt Out of It.
<speedSetting> and <latencySetting>
| Block | Fields | Notes |
|---|---|---|
|
|
Required when |
|
|
Required when |
An enum value is matched against the constant names without regard to case, and that is a fix rather than a courtesy. The writer emits a constant’s name() verbatim, and the reader used to upper-case the text before looking it up — so the first constant that was not ALL_CAPS made a document this service had just exported unreadable by its own importer. speedTierMode was that constant. Case tolerance is not value tolerance: an unknown value is still refused, naming the ones that exist.
Carrying the wrong block, or the right block for the wrong type, is refused rather than silently reconciled — the document has to be internally consistent about what it is describing, exactly as PUT /FCC/item/details refuses it (The Settings Block Must Match the Type).
Booleans accept only true / false
FccConfigRecordFields.convert parses a boolean element with an explicit two-way check, never Boolean.parseBoolean:
onlyOnline=yes is not a boolean; allowed: true, false
Boolean.parseBoolean answers false for every string that is not (case-insensitively) "true" — so a document carrying <onlyOnlineDevices>yes</onlyOnlineDevices> would silently store false: a campaign measuring offline devices too, with nothing anywhere to say the value was never understood. A type this format cannot express is refused as the caller’s mistake it is, not coerced into the wrong value.
Absent, or blank, means null — and that is load-bearing, not an implementation detail
An element that is not in the document, and an element that is present but empty or whitespace-only, both become null in the request record FccConfigRecordFields.build constructs. This is not a detail of how the reader happens to work: it is the precondition for the three-level settings chain to do anything at all on import. The chain fills a field only when it is absent from the request; a 0 or an empty string written where the source had nothing would beat the chain and freeze the exporting installation’s blanks into the imported campaign, silently, on every field the chain exists to fill.
The Vocabulary Is Derived From the Request Records, Not Hand-Written
FccConfigXmlNames deliberately does not name the fields of <activation>, <speedSetting> or <latencySetting>. FccConfigRecordFields reads them off the record components of FccTestActivationRequest, FccSpeedSettingRequest and FccLatencySettingRequest by reflection — the exact records PUT /FCC/item/details and POST /FCC/item/details already use — both to render them into XML on export and to build a record back from the parsed elements on import.
This is why the XML and the JSON of PUT /FCC/item/details cannot drift into two vocabularies for one set of settings. The consequence worth stating explicitly: a field added to one of those three records appears in the document with no change to this package. A column added to fcc_test_config reaches FccTestActivationRequest because the REST API has to carry it; from there it reaches the XML format automatically, both directions, the same release. A hand-written element list is the second place the field would have to be added, and the one most likely found months later as an imported campaign quietly missing a setting nobody remembered to wire up twice.
The complement is equally deliberate: a field renamed on one of those records is renamed in the document too, in the very next release. That is the honest consequence of deriving the vocabulary rather than an accident — a renamed setting genuinely is a new format for that one field, and formatVersion (formatVersion Is Required: 3 Is Written, 2 and 3 Are Read) exists to let a receiver refuse a document it cannot read instead of silently dropping the value under its old name.
|
That happened in this release. DEV-1381 renamed nineteen fields of
|
A <test> Carries One <configuration> Per Configuration
This is what version 2 of the format is. A campaign holds several configurations — see Several Configurations, and Whose Limit Bounds Them — so the document carries one <configuration> element per configuration, each with its own <type>, its own settings block and its own <devices>. In version 1 those three sat directly inside <test>, which is the one-measurement-per-campaign shape the schema no longer has.
<configuration> is the one repeatable element of a <test> and is therefore the one exempt from the declared-more-than-once rule: every other element of a <test> states a single fact about the campaign, and a document stating one of them twice is a file somebody assembled wrongly.
<activation> stays at the <test> level and is NOT repeated, which mirrors the contract rather than flattening anything: the timing knobs — the durations, the retry counts, the WAN timeouts — are stated once per campaign in activation, and the save writes those columns identically to every configuration row. The export takes them from the first configuration, which is the one that always exists.
Each configuration’s fleet is resolved on its own. The reader hands the serials to its sink tagged with the configuration’s position, so a document of three configurations produces three fleets rather than one list of everything — and the fleet ceiling is per configuration too, naming which one overran:
<configuration> 1 accepts at most 20000 devices, got 20001
A <test> with no <configuration> at all is refused: it describes no campaign, and answering a form with no configuration would be an operator pressing Save on something the service then refuses, with the file long gone from the screen.
<speedSetting> Gained Two Elements With No Change to This Package
Contract 1.6 added timeBasedDurationSec and timeBasedIntervalSec to FccSpeedSettingRequest, so the document gained <timeBasedDurationSec> and <timeBasedIntervalSec> inside <speedSetting> with no change to this package at all — that is the vocabulary being derived working as intended, and FccConfigRoundTripIT asserts that the freedom was real rather than assumed.
That is also the contrast that makes Version 3: What Version 2 Was Losing worth reading: the fields that got LOST were the ones this package named by hand.
Version 3: What Version 2 Was Losing
A fully populated campaign was saved, exported, imported and compared field by field against itself. Seven fields did not come back, in two groups, and every one of them had been stored on the exporting installation all along:
| Lost in version 2 | What it meant in practice |
|---|---|
|
Version 2 carried the recurrence as a bare |
|
The document carried the FLEET and not the choice that produced it, so every imported configuration came back as an explicit device list — |
Version 3 carries both groups. The recurrence travels as a <reactivation> block, written from FccReactivationRequest by the same reflection as <activation> — so type, repeatEvery, startsOn, ends and reactivationCount are all in it, and a field added to that record will travel with no change here. The configuration’s choice travels as four elements beside its <type>.
Why reactivation.startsOn Travels When activation.date Does Not
This looks like the format relaxing its own instant-vs-duration rule (What the Format Deliberately Does Not Carry), and it is not. The distinction is what the instant DOES:
-
activation.dateis a booking. It fires. A document written in March and imported in June would either fire at once or be silently dropped, so the document carries no schedule at all and the operator books the campaign after the import. -
reactivation.startsOnis the phase of a rhythm. Every run lands onstartsOn + n * repeatEvery, andFccReactivationService.nextWindowStartAtsteps that forward one period at a time until the next slot is in the future — so a stale anchor resumes the rhythm instead of firing a backlog. Dropping it loses the only thing that says when in the week a weekly campaign runs.
It is also the format’s only Instant, and adding it needed one thing on the read side: FccConfigRecordFields.convert knew String, Integer, Long, Boolean and enums, and the writer’s render would happily have written an Instant that the reader then refused with an internal error. The write side and the read side of a format have to know the same types.
The Consequence of Carrying sourceType
An import now answers the source type the document states, so a save of the imported form does what that source type means: All and Condition re-resolve the fleet on the importing installation rather than using the serials in the file. That is the operator’s visible decision on a screen that shows their own choice, instead of a silent downgrade to a device list. The serials are answered in selectedDevices either way, so the form is complete whichever they pick.
formatVersion Is Required: 3 Is Written, 2 and 3 Are Read
The root element must carry formatVersion:
| What the attribute is | Answer |
|---|---|
Missing, or blank |
400/9400: |
Non-numeric |
400/9400 naming the literal that was found. |
|
Read in full. This is what an export writes — see Version 3: What Version 2 Was Losing. |
|
Read, and it simply says less: no |
Any other number |
400/9400: |
A VERSION-1 DOCUMENT IS REFUSED AS A WHOLE and is not read as a single configuration, although it could be: its <type>, its settings block and its <devices> sit inside <test>, which is exactly one configuration’s worth of content. Reading it that way would mean the reader carrying two shapes of <test>, and a half-applied import is the outcome this format’s strictness exists to prevent.
A DOCUMENT CARRYING BOTH SPELLINGS OF THE RECURRENCE IS REFUSED. <reactivationDays> is version 2’s count and <reactivation> carries that same count as reactivationCount, so a file with both states one fact twice and the two can disagree — which is the situation this format refuses everywhere else rather than picking a winner from. It is the same rule that removed configs[].diagnosticsType from the REST contract (the settings block must match the type).
All of the above are refused before the first <test> is even looked at — readRoot() reads the root and its attribute before nextTest() is ever called — except the both-spellings refusal, which needs the <test> it is in.
Why refuse an unknown version rather than default to the current one. An older service reading a newer document would otherwise read it as its own format and silently drop whatever changed. formatVersion is the seam an incompatible release hangs off; a document from a LATER release carries elements this reader would refuse one at a time, and refusing the file by version says so once.
What the Format Deliberately Does Not Carry
| Not carried | Why |
|---|---|
|
A schedule is an instant, not configuration. A document written in March and imported in June would either fire the campaign immediately on import or be silently discarded on the way in — neither is a defensible reading of "the same time" across two dates that far apart. An imported campaign is always created |
|
The lifecycle of a campaign belongs to the installation that runs it. An imported campaign always starts |
The whole |
A cadence and a count are durations, and a duration means the same thing wherever it is read. They are genuine configuration and travel with the rest of it. The rule the format draws is not "nothing about timing": it is that an instant which FIRES stays behind, while a rhythm travels — which is why |
|
An installation-local surrogate key. It identifies nothing in another installation. A fleet only ever travels as serials — see The Fleet Travels as Serials, and an Unresolved Serial Fails the Parse. |
Nothing about the device selection — this row was wrong, and the bug is Version 3: What Version 2 Was Losing |
It said the selection "is not stored anywhere to export". That stopped being true at DEV-1381, which put |
|
|
|
Evidence of runs that happened. Copying them under a new campaign id in another installation would invent runs that did not happen there. |
Each of these is refused by name, not silently ignored, if a hand-edited or future document carries it — see Unknown Elements Are Refused, Not Skipped.
The Fleet Travels as Serials, and an Unresolved Serial Fails the Parse
<devices> carries <serial> elements only, read in batches of 1000 and resolved against ftacs.cpe through FccDeviceResolveService.resolveForSave — the identical path PUT /FCC/item/details uses for a sourceType=All configuration (Where the Fleet Comes From). Only the resolved cpeId is kept; the serial, model and protocol of the stored snapshot are re-derived from ftacs.cpe, never trusted from the document.
A serial that matches no device visible to the caller fails the parse with 400/9400, naming the count and the offending serials in the caller’s own spelling — it is resolveForSave’s own rule, raised here rather than at save time so that the operator reads it while looking at the file instead of after filling in a form. This is deliberately not the wizard’s behaviour: `POST /FCC/devices/resolve reports an unmatched serial in notFoundSerials and returns the rest of the fleet, because there the operator is still choosing. A campaign holding 19,000 of the 20,500 serials a document named is a compliance report missing 1,500 subscribers with nothing later to reveal it — and since the save applies the same rule a moment later, accepting a shortened fleet here would only move the refusal to a screen the operator has already filled in.
An absent <devices> element means an empty fleet — the normal case for moving a configuration to an installation with a different device inventory — and is a legal draft, exactly as an empty fleet is on PUT /FCC/item/details.
The ft.fcc.max-devices-per-test fleet ceiling is checked in the serial sink, as the batches are read, and before a single one of them is resolved. FccConfigImportService.requireWithinFleetLimit counts them as they arrive, so a document naming 300 000 serials stops at the batch that crosses the limit rather than issuing roughly 300 IN (…) queries against the ten-connection, read-only ftacs pool — shared with the device wizard — on its way to being refused. The wording is `FccTestValidator’s, byte for byte, on purpose: a caller must not be able to tell which layer refused, or the two messages would drift apart and one of them would become the one nobody tests.
The Import Parses and Returns, and Creates Nothing
PUT /FCC/item/import answers the details object — the very body POST /FCC/item/details answers, which is the body PUT /FCC/item/details accepts — and writes nothing. DEV-1381 #13 asked for "the same as PUT /GroupUpdate/groups/import`", and that endpoint does exactly this: `GroupUpdateDetailsService.getGroupFromFile parses the XML and returns GroupUpdateGroupDetailsRequest, the form payload, un-saved. Its screen shows the form and the operator presses Save, which is a different endpoint.
id is null — precisely what the save reads as "create" — and state is NOT_ACTIVE, which is not a state anything is in but the state the save will create. activation.type comes back as Immediately: the document deliberately carries no schedule (What the Format Deliberately Does Not Carry), and answering the parsed null would leave the form’s own radio unset on a screen the operator is about to save.
|
What that removed, and it was a lot: the per-test transaction, the per-test result item ( None of them has anything to describe any more. A document cannot be half-applied by a method that applies nothing, so every refusal below is a plain |
One <test> per Document
The format carries many — <fccTests> holds <test> elements, plural — but one details object describes one campaign, so a second <test> is a 400 rather than a silent truncation. Answering the first of three while discarding two is precisely the half-applied import this format’s strictness exists to prevent, and the operator would have no way to tell: the answer looks like a complete success.
PUT /FCC/item/export writes one campaign per file for the same reason (Export), so a document this installation produced always imports. One assembled by hand from several exports has to be split.
What the Parse Judges, and What It Leaves to the Save
The document is judged here: its vocabulary (Unknown Elements Are Refused, Not Skipped), its formatVersion (formatVersion Is Required: 3 Is Written, 2 and 3 Are Read), its own internal consistency — a <test> with no <configuration>, a <configuration> with no <type>, a settings block that does not match its configuration’s <type> — and the two bounds a parse cannot postpone: the fleet ceiling ([fleet-ceiling-checked-early]) and the unresolved serial (The Fleet Travels as Serials, and an Unresolved Serial Fails the Parse).
The campaign is judged by PUT /FCC/item/details when the operator saves what they were shown: the URL and range rules, the connection ceilings of the resolved fleet, the three-level settings resolution and the domain. Running them here as well would mean two places judging one campaign, and the one that fired first would decide whether the operator ever sees the form.
Unknown Elements Are Refused, Not Skipped
| Where the unknown element is | Outcome | Reasoning |
|---|---|---|
Inside |
That one |
|
Directly under |
The whole document is refused, 400/9400, before anything is written |
The document’s vocabulary is wrong at that point, not one campaign’s — continuing would mean guessing which of the remaining elements are tests. |
An element the format deliberately does not have — scheduledAt, state, source, id, legacyUgId, activationCount, cpeId — gets its own message naming why it is absent rather than the generic "unknown element" text, because "unknown element scheduledAt" invites the conclusion that the format is incomplete, when it is deliberate:
<scheduledAt> is not part of the configuration document format: a schedule is an
instant, not a configuration: a document produced in March and imported in June
would either fire at once or be dropped. An imported campaign is always created
NOT_ACTIVE and is scheduled on this installation. Remove it and import again
Why strictness was chosen over ignoring the unknown element. Skipping it would be the friendlier-looking behaviour, and it is the wrong one for this format. An unknown element is either a typo — in which case the setting the operator meant to set is silently not applied — or a field belonging to a later formatVersion, in which case the campaign would be created with part of the configuration the document actually describes and nothing would say so. Both produce a campaign that measures something other than what the file states, which is the one outcome a compliance product must not have. Silently ignoring an unknown element is also exactly how a document from a later format version gets half-applied by an older service — the failure mode formatVersion and this rule exist together to prevent.
Legacy Anchors Are Refused in Neither Direction, Never Silently Skipped
A legacy anchor — source = LEGACY or state = ARCHIVED — is not a configuration at all: the M4 ETL creates it purely to hang imported measurements from (Legacy FCC Import), so it has no fcc_test_config row and no settings row, and its legacy_ug_id points into this installation’s ftacs and means nothing anywhere else. ARCHIVED is also unreachable through evaluate() (fcc_test Transition Table) — nothing this service does ever puts a live campaign there.
| Direction | Behaviour |
|---|---|
Export, an id naming a legacy anchor |
400/9400, the whole request refused before the first byte, naming every offending id. Exporting one would produce a |
Import, an unknown element under |
400/9400 for that test, with the reason — see Unknown Elements Are Refused, Not Skipped. There is no way to ask for a legacy anchor on import: the format has no vocabulary for one. |
Why refused rather than silently skipped, on export in particular. The caller named the id explicitly. A document that quietly holds four of the five campaigns asked for is a document whose missing campaign nobody notices until a compliance run turns out not to exist on the target installation. A silent skip is precisely the failure mode this rule exists to prevent — the same reasoning PUT /FCC/item/details applies to an unresolved cpeId (PUT /FCC/item/details) and FccCatalogService.catalog applies to an empty domain intersection (The Empty Intersection Selects Nothing).
The Settings Chain, and How to Opt Out of It
The exported document carries the campaign’s resolved values — what the three-level chain (request, fcc_domain_setting, FT Configs) produced when the operator originally saved the campaign, not the raw request that produced them (The Settings Resolution Chain).
On import those values arrive as explicit request values — level one of the chain on the importing installation — and level one always wins. This is intended, not an oversight: the whole point of export/import is to reproduce a campaign, not to re-derive it against a different installation’s defaults.
The documented way to inherit the target installation’s own defaults is to delete the elements. An operator who wants the target’s per-domain test servers or thresholds removes <downloadUrl>, <uploadUrl> or the latency <testHost> from the file before importing it, because the chain only ever fills a field that is absent (Absent, or blank, means null — and that is load-bearing, not an implementation detail). There is deliberately no flag for this: a flag would have to choose per field, which is exactly what editing the file already does, per field and visibly, with the resulting document readable by anyone who opens it.
Which Domain an Imported Campaign Lands In
The save decides, and the document’s own <domainId> is read and ignored.
It names a domain of the installation that wrote the file, which means nothing here; and since the import creates nothing, there is no landing domain to decide. PUT /FCC/item/details is where a campaign acquires one — from X-FT-Domain-Id under scope LIST, or from the body’s domainId for a super-domain caller (domainId on create) — and the details object this endpoint answers carries no domain field at all.
That is a real simplification over what this page used to describe. The import had its own domain-entitlement table: honour a visible <domainId>, refuse a foreign one with 403/9403 per test, refuse 0 or negative, and refuse an absent one under scope ALL; it had to both judge the domain and construct the identity each upsert was made with, and it judged it before the test’s fleet was resolved so that a refused test did not spend 20 000 serial lookups first. All of that existed because the import wrote rows. It does not.
The device fleet is still resolved against the caller’s whole visible domain set. identity.domainIds() goes to FccDeviceResolveService.resolveForSave unchanged, so a file naming devices of a domain the caller cannot see resolves to nothing visible and is refused — rather than being silently narrowed.
Transport
Export
PUT /FCC/item/export answers application/xml as a StreamingResponseBody, with Content-Disposition: attachment; filename="fcc-<campaign name>-<yyyyMMdd-HHmmss>.xml" and no Content-Length — the size is unknown until the last device of the fleet has been read, which is the point of the endpoint. Since DEV-1381 #12 it writes one campaign per file ({"id": 0}), and the import reads one (One <test> per Document), while the format itself still carries many. Every refusal — an id that does not exist or is not visible, a legacy anchor — is decided in prepare(), inside one @Transactional(readOnly = true) block, before the StreamingResponseBody is even constructed. Once the status line is on the wire, a failure can only close the connection; the client sees a truncated file with no error anywhere in it. This is the identical rule, for the identical reason, as POST /FCC/reports/export (POST /FCC/reports/export).
Import
PUT /FCC/item/import accepts multipart/form-data only, part file, and nothing else — there is deliberately no raw application/xml variant of this path.
Two independent reasons:
-
A second content type on one path is what
docs/openapi/README.md’s "two doubled paths" post-processing step exists to work around.springdoc cannot express two operations for one path and method; `/FCC/devices/resolve and/FCC/devices/countalready pay this cost for JSON-versus-multipart, and it is a cost this endpoint does not have to add a second time when one content type already does the job. -
The multipart path is the only bounded one, on a service with no authentication of its own.
spring.servlet.multipart.max-file-size/max-request-size(FCC_SERIAL_FILE_MAX_SIZE, default 10 MB) cap the upload; a rawapplication/xmlbody read straight off the request has no equivalent limit at the framework level, on a port that trusts every caller that can reach it (No Authentication by Design).
The existing multipart handlers already answer the shared vocabulary: an oversized upload is 413/9413 naming the configured maximum, a missing file part is 400/9400, and a part offered with an unsupported content type is 415/9415 — see the multipart status codes, which this endpoint reuses rather than reimplementing.
The document itself is pull-parsed one <test> at a time with DTDs and external entities disabled on the XMLInputFactory — switched off on the factory itself, not sanitised afterwards, which is the only form of that guarantee that cannot be forgotten (No Authentication by Design is the same reasoning that makes an XXE here worth closing off explicitly: anything that can reach this port can send this document).
The multipart upload’s InputStream is closed by FccConfigImportService, the caller of FccConfigXmlReader — never by the reader itself. FccConfigXmlReader states this explicitly in its own javadoc rather than leaving it to be inferred, because it was a real leak as the endpoint first shipped: XMLStreamReader.close() is specified by JSR-173 not to touch the underlying source it was given, so try-with-resources around the reader alone released the StAX parser and nothing else. With spring.servlet.multipart.file-size-threshold at Boot’s default of 0, every part is spooled to a temp file regardless of size, so every import leaked a file descriptor and up to FCC_SERIAL_FILE_MAX_SIZE of an unlinked-but-open inode, with nothing in the log to say so. importDocument now wraps the stream itself in its own try-with-resources, outside the reader’s.
Round-Trip: What Survives, and What Does Not
| Category | Fields |
|---|---|
Identical after export → import |
Everything else, and that is now asserted rather than claimed: |
Different by design |
|
The test’s list of permitted differences is exactly that second row, and it is the only place the exemption is written down — CREATED_BY_THE_SAVE, with the reason beside each entry.
A field edited out of the document before import — most often downloadUrl / uploadUrl / the latency testHost — is the one legitimate way the imported campaign’s stored values differ from the source’s: the settings chain fills it from the importing installation’s own domain default or FT Configs default instead (The Settings Chain, and How to Opt Out of It).