This serves a mock Files.com API server, which is useful for testing things like the Files.com SDKs and other direct integrations against the Files.com API.
Files.com is the cloud-native, next-gen MFT, SFTP, and secure file-sharing platform that replaces brittle legacy servers with one always-on, secure fabric. Automate mission-critical file flows—across any cloud, protocol, or partner—while supporting human collaboration and eliminating manual work.
With universal SFTP, AS2, HTTPS, and 50+ native connectors backed by military-grade encryption, Files.com unifies governance, visibility, and compliance in a single pane of glass.
The server has two modes, chosen when it starts:
- Legacy mode (the default) is a simple Grape app with generated definitions for each API endpoint. It checks required parameters and parameter types, then returns a fixed example response. It does not maintain state and it does not deeply inspect your submissions for correctness. This is useful for testing basic network operations and JSON encoding for your SDK or API client.
- Simulation mode (opt-in) keeps records, files and folders in memory, so a test can create, find, update, list and delete any regular API resource, page through results, upload a file and download the same bytes, copy, move and delete folders, and make chosen requests fail, stall or lose their connection on purpose, reproducibly. Requests it does not simulate fail with a clear error instead of returning an example response.
Neither mode checks credentials. Send any placeholder API key; never use real Files.com credentials with the mock server.
To run this server with Ruby (Local Ruby Usage):
- Ruby 3.3.12 or newer.
bundle installinstalls the gem versions inGemfile.lock, and some of them require Ruby 3.3 or newer. - Bundler
To run it in Docker (Docker Image Usage), you need only Docker: the image brings its own Ruby (this directory's Dockerfile uses Ruby 3.4.11).
This directory is generated by the Files.com SDK generator from one Swagger document. To build the same server again, run the generator from a clean checkout with that document, pinned by its SHA-256:
shasum -a 256 public.json # compare with the SHA-256 you pinned
ruby files-sdk-generator.rb --target mock-server --file public.jsonThe server records what it was generated from in
lib/simulation/generation.json: the Swagger document's SHA-256, the
generator's commit and whether its sources were clean, and the SHA-256 of
the schema subset it validates requests against. Its readiness report
(GET /__files_mock/v1/ready) repeats that schema's SHA-256, so a test can
check that the running server is the one it expects. A server generated
from another document, or from a changed generator, is another server.
Install dependencies once:
bundle installStart the legacy server, which listens on port 4041 on all IPv4
interfaces (0.0.0.0):
bundle exec pumaStart the simulation server, which listens on 127.0.0.1:4041:
FILES_MOCK_MODE=simulation bundle exec pumaFILES_MOCK_MODE is read once at startup. Leaving it unset, empty, or
set to legacy starts the legacy server. Any other value stops startup
with an error, so a typo cannot silently start the wrong server.
To choose another port while keeping the simulation server on loopback,
pass Puma's -b option, for example -b tcp://127.0.0.1:4051. Read
Ports and interfaces before using -p or your
own Puma configuration file.
The published filescom/files-mock-server:latest image is a moving tag: it
is built from whatever server was last published, so it need not contain
the simulation mode, schema or contract version this directory describes.
To run exactly this server in Docker, build the image from this generated
directory and run it by the image ID you recorded, not by a tag:
docker build -t "files-mock-server:$(cat _VERSION)-local" .
image=$(docker image inspect --format '{{.Id}}' "files-mock-server:$(cat _VERSION)-local")
echo "$image" # record this ID (sha256:...) with your test results
docker run -p 40410:4041 -it "$image"The server in the image is this directory's, so its generation receipt
(lib/simulation/generation.json) and its readiness report (below) say
which schema it was generated from; check them before you rely on a run.
The image starts from the official ruby:3.4.11-bookworm image (Ruby
3.4.11 on Debian 12), pinned by its digest, and keeps that image's
RubyGems. It installs the Bundler that Gemfile.lock names (4.0.21), then
exactly the locked gems, frozen: a Gemfile that disagrees with
Gemfile.lock fails the build instead of changing the lock. The pin stops
the base from changing between builds; it does not keep the base's
operating system, Ruby or RubyGems up to date. Only linux/amd64 builds have
been checked.
Before the gems, the build applies the security fixes the pinned base does
not have yet. image-updates.sh pins each one by version and SHA-256:
Debian 12's OpenSSL 3.0.22 (libssl3, libssl-dev, openssl) and
libde265 1.0.11-1+deb12u3 from Debian's security archive, and json 2.19.9
from rubygems.org. json 2.19.9 replaces Ruby 3.4.11's default json 2.9.1
(CVE-2026-54696), whose files the build removes, so Ruby run outside the
bundle loads the fixed version; the server itself runs with the json
Gemfile.lock names. When Debian replaces these package versions with
newer fixes, the pins in image-updates.sh need updating: an online build
fails once Debian's archive no longer serves the pinned versions.
To build without network access, fill vendor/cache first. In this
directory, on a machine with network access, curl and Ruby 3.3.12 or newer:
gem install bundler --version 4.0.21
BUNDLE_FROZEN=true bundle cache --no-install
(cd vendor/cache && gem fetch bundler --version 4.0.21)
sh image-updates.sh fetch vendor/cachebundle cache collects every gem Gemfile.lock names, but not Bundler
itself, which gem fetch adds; image-updates.sh fetch downloads the
pinned Debian packages and json gem and checks each SHA-256. When
vendor/cache exists, the build installs the security fixes only from
it, and a missing file or one with another SHA-256 fails the build. It
then installs Bundler from its cached archive and uses cached gems or gems
already installed in the base. A dependency missing from both fails
instead of being fetched from rubygems.org. Provide the whole cache with
the commands above, or remove the directory to use online installation.
The cached Debian packages are amd64 builds, so a build from
vendor/cache is for linux/amd64 only (docker build --platform linux/amd64 on other machines); an online build installs the same
versions for its own architecture. Only the image's installation steps
are offline: the Docker daemon still needs the base image, and may contact
the registry to resolve it even when the image is already present.
Example:
curl 127.0.0.1:40410/api/rest/v1/usersTo run the simulation server in Docker, tell it to listen on the container's interfaces and publish the port on your machine's loopback address only:
docker run --rm -e FILES_MOCK_MODE=simulation -e FILES_MOCK_TRANSFER_ORIGIN=http://127.0.0.1:40410 -p 127.0.0.1:40410:4041 "$image" -b tcp://0.0.0.0:4041FILES_MOCK_TRANSFER_ORIGIN tells the simulator the address your tests
use, so the upload and download URLs it returns point at the published
port (see Upload and download URLs).
In CI, a simulation server can run as a service container bound to
tcp://0.0.0.0:4041 when the job network is isolated to that job. Wait
for GET /__files_mock/v1/ready, check that it reports the schema you
expect, then reset it with your fixtures and profile before each scenario:
docker run --rm -d --name files-sim -e FILES_MOCK_MODE=simulation -e FILES_MOCK_TRANSFER_ORIGIN=http://127.0.0.1:40410 \
-e FILES_MOCK_MAX_BODY_BYTES=8388608 -p 127.0.0.1:40410:4041 "$image" -b tcp://0.0.0.0:4041
until curl -fs 127.0.0.1:40410/__files_mock/v1/ready > /dev/null; do sleep 0.2; done
curl -s 127.0.0.1:40410/__files_mock/v1/ready | ruby -rjson -e 'puts JSON.parse(STDIN.read).values_at("contract_version", "schema_sha256")'
curl -s -X POST 127.0.0.1:40410/__files_mock/v1/reset -H 'Content-Type: application/json' -d '{"fixtures": {"users": [{"username": "alice"}]}}'For https URLs, mount a certificate for 127.0.0.1 and bind TLS inside
the container, for example
-v "$PWD/tls:/tls:ro" ... -b 'ssl://0.0.0.0:4443?key=/tls/key.pem&cert=/tls/cert.pem&verify_mode=none'
with FILES_MOCK_TRANSFER_ORIGIN=https://127.0.0.1:40443 and
-p 127.0.0.1:40443:4443.
The bundled config/puma.rb chooses the interface for each mode: the
legacy server listens on all IPv4 interfaces (tcp://0.0.0.0:4041) and
the simulation server on loopback (tcp://127.0.0.1:4041). Puma's -b
option replaces that bind, so -b tcp://127.0.0.1:4051 moves the
simulation server to another port while keeping it on loopback.
Puma's -p PORT option also replaces the configured interface,
including the simulation server's loopback default. It binds :: (all
IPv6 interfaces, which on most systems also accept IPv4 connections)
when the machine has a non-loopback IPv6 interface, and 0.0.0.0
otherwise. Use -b tcp://0.0.0.0:PORT for an IPv4-only bind.
The simulation server's loopback default, its refusal to run with Puma
workers, Puma's own request body limit and the way Puma reads request
bodies (see Limits) all come from the bundled
config/puma.rb. If you start Puma with your own configuration file
instead, bind to loopback, run without workers, and set
http_content_length_limit and queue_requests false yourself.
Everything below applies only when FILES_MOCK_MODE=simulation.
| Area | Simulated | Refused (501) or not simulated |
|---|---|---|
| Records | List, create, find, update and delete of every resource with the regular /{name} and /{name}/{id} shape, records without an id kept under the parent they were created for, group memberships found by their own fields, lists scoped by path or owner, Style records keyed by path, log lists and delete-only resources filled by fixtures, configuration-dependent answers and a key lookup from fixtures, and the site singleton (GET/PATCH /site). Increasing IDs per resource, exact types, write-only secrets never returned, file parameters decoded, checked and reported. |
Actions (such as /users/{id}/unlock), each with the decision or real owner it needs, filtering, sorting, search, uniqueness, foreign keys. GET /__files_mock/v1/inventory lists every operation and why. |
| Files and folders | Uploads (known size, parts in order or not, per the upload profile), finalize with or without etags, whole and ranged downloads, stat, metadata, PATCH /files/{path} metadata, folders, listing, delete, recursive delete, copy and move with FileAction results, pending actions with FileMigration polling, ZIP listing, extraction and saving (file_actions/zip), file and folder fixtures, underscore destinations with their scope journaled. |
Remote server mounts, lock enforcement (locks are advisory), permissions, file previews (with_previews lists a page of folders only), copy_behaviors, other spellings of existing paths, the stores underscore destinations name. |
| Locks | Advisory locks on paths, found as the model finds them (by byte prefix): created when both public flags are true, listed with the locks above that apply to subfolders and, on request, the path's range one level deep, as one sorted array, released with their token, exclusive conflicts with the model's message, expiry on a lock clock the tests move, and a separate cleanup of expired locks. | Refreshing a lock or anything else its internal parameters do, shared or owner-only locks, who may act on another user's lock, the real removal job's timing. |
| Transfer profiles | Legacy (default), serial and parallel part rules, retries forbidden, advertised part sizes (hints) or variable part limits, scheduled part size changes for parts not yet issued, part offsets, target class, a shared part concurrency limit with 503 throttling, presigned URLs, the download identity contract, the historical download request status protocol (an ID on each successful storage response and a chosen status at the download URL joined with it), withheld download sizes, cursors with special characters. |
Provider-specific limits beyond the profile, adaptive tuning or throughput claims. |
| Faults | Deterministic and seeded random rules per operation, record, path, part, session and attempt: Files.com errors with chosen types and data, unstructured and web-tier answers, redirects, expired URLs, frontend HTML, empty pages, delays before or after a request is applied, dropped connections before or after a commit, truncated, short, padded and stalled bodies. |
Faults outside the local HTTP server's reach, such as TLS handshake failures. |
| Observation | A bounded journal of every API and transfer request: operation, status, wire types, fault used, identity headers by number (never values), user agent, timing, bytes and digests, file parameters by filename and digest, the store an underscore destination names, and each user listing's paging values and selected cursor headers by type and digest. | Real authentication, permissions and session semantics. |
Anything a test needs a real Files.com site for (authentication, permissions, workspaces' effects, providers, performance) stays a real or native check; a pass here never stands in for it.
| Operation | Request | Success |
|---|---|---|
users.create |
POST /api/rest/v1/users |
201 and the new user |
users.list |
GET /api/rest/v1/users |
200 and an array of users |
users.find |
GET /api/rest/v1/users/{id} |
200 and the user |
users.update |
PATCH /api/rest/v1/users/{id} |
200 and the updated user |
users.delete |
DELETE /api/rest/v1/users/{id} |
204 with an empty body |
files.begin_upload |
POST /api/rest/v1/file_actions/begin_upload/{path} |
200 and an array of upload parts, one unless parts asks for more |
files.finalize_upload |
POST /api/rest/v1/files/{path} with action: "end" |
201 and the new file, 200 when it replaces one |
files.download |
GET /api/rest/v1/files/{path} |
200 and the file with a download_uri; with action=stat, the file or folder without one |
files.metadata |
GET /api/rest/v1/file_actions/metadata/{path} |
200 and the file or folder |
files.delete |
DELETE /api/rest/v1/files/{path} |
204 with an empty body |
folders.create |
POST /api/rest/v1/folders/{path} |
201 and the new folder |
folders.list |
GET /api/rest/v1/folders/{path} |
200 and an array of the folder's files and folders |
files.copy |
POST /api/rest/v1/file_actions/copy/{path} |
201 and a FileAction |
files.move |
POST /api/rest/v1/file_actions/move/{path} |
201 and a FileAction |
file_migrations.find |
GET /api/rest/v1/file_migrations/{id} |
200 and the FileMigration of a pending copy, move, unzip or ZIP |
files.update |
PATCH /api/rest/v1/files/{path} |
200 and the file or folder with the metadata it sets |
files.zip_list |
GET /api/rest/v1/file_actions/zip_list/{path} |
200 and the ZIP file's entries (path, size); only when the Swagger document declares it |
files.unzip |
POST /api/rest/v1/file_actions/unzip |
201 and a pending FileAction; its FileMigration extracts the entries; only when the Swagger document declares it |
files.zip |
POST /api/rest/v1/file_actions/zip |
201 and a pending FileAction; its FileMigration saves a ZIP of paths as destination; only when the Swagger document declares it |
locks.list_for |
GET /api/rest/v1/locks/{path} |
200 and an array of the locks that apply to the path; see Locks |
locks.create |
POST /api/rest/v1/locks/{path} |
201 and the new lock |
locks.delete |
DELETE /api/rest/v1/locks/{path}?token=… |
204 with an empty body |
The three lock operations are simulated when the Swagger document
declares all of them and the Lock object. Every other regular resource is
simulated as records; see Record resources. Uploaded bytes go to the
upload_uri each upload part names, and downloads come from the
download_uri; see Files and Folders.
Send parameters the way the Files.com SDKs do: in the query string for
GET and DELETE, and as a JSON object body with
Content-Type: application/json for POST and PATCH. A form body
(application/x-www-form-urlencoded), as the Ruby SDK sends, is read
within FILES_MOCK_MAX_BODY_BYTES and parsed by Rack within its nesting
depth and parameter count limits: names such as etags[][part] make
arrays and objects, and every value is a string, as in a query string.
A body value replaces a query value of the same name. A body that does
not parse gets 422 bad-request/invalid-body, and a query string that
does not parse gets 422 bad-request.
Any other request under /api/rest/v1/ returns 501 with the type
simulation/not-supported (a HEAD request gets the same status with an
empty body), and so does a simulated operation that sends a parameter the
Files.com API schema declares but the simulator does not model. The
readiness response below lists the simulated operations.
How users behave:
- IDs start at 1 and increase by one for each created user. A deleted user's ID is never reused until the next reset.
- A created user contains its
idand the fields you supplied that are part of the User object. Fields the real API computes or defaults (such ascreated_at) are not simulated. - An update changes only the fields you supply and keeps the ID and
every other field. Sending
nullfor an optional field clears it. - Finding, updating, or deleting a user that does not exist returns
404with the typenot-found, like the Files.com API. password,password_confirmation,change_password,change_password_confirmationandimported_password_hashare checked to be strings and then discarded. They never appear in responses or in the journal.- Parameters are checked against the Files.com API schema before
anything changes: required parameters, strings, whole numbers
(including 32-bit ranges),
true/false, enumerated values such asssl_requiredandauthentication_method, and date-times such asauthenticate_until. Date-times must be ISO 8601 with a UTC offset (for example2030-01-02T03:04:05+02:00) and are returned in UTC (2030-01-02T01:04:05Z), to whole seconds: fractional seconds are discarded, so2030-01-02T03:04:05.750Zis returned as2030-01-02T03:04:05Z. Dates and times that do not exist, such as February 30 or 24:00, are rejected rather than rolled over. Invalid values return422with the typebad-request, and nothing is created or changed. avatar_fileis a file parameter (see File parameters). Schema-declared parameters with effects the simulator does not model, such asgroup_idornew_owner_id, return501.- Keys the schema does not declare for the operation are ignored, as the
Files.com API ignores them. A misspelled parameter name, such as
perpage, is therefore not reported. - The simulator does not check credentials, username uniqueness, or whether IDs in one record refer to other records.
Every resource whose operations have the regular shape, a collection at
/api/rest/v1/{name} (list, create) and members at
/api/rest/v1/{name}/{id} (find, update, delete) answering with one
entity that has an id, is kept as records the way users are:
- IDs start at 1 for each resource, increase by one and are never reused before a reset.
- An
idthe schema types as a string (chat sessions, DNS records, IP address lists) is the record's own data instead: a fixture supplies it, lists and finds return it exactly as given (left out when unset,nullwhen null), andGET /api/rest/v1/{name}/{id}finds the record holding it, the segment decoded once. No two records may hold the same one. The simulator never makes one up, and such ids name no real chat, DNS record or address. Lists still page in the order records were loaded. - A record holds exactly what was supplied: a field never set is left out
of responses, an explicit
nullis kept asnull, andfalse,0and empty strings stay as sent. Int64 values keep every digit, decimals stay exact strings (a JSON number is kept at its exact value), and arrays and objects keep their elements. A decimal string may use an ordinaryeorEexponent (1E-7,1.5E+3), which the API's parameter coercion reads, and is kept as sent, never expanded or converted through a float; other spellings (.5,12.,+1,1_000, surrounding spaces) get422. How the API spells a decimal it returns, and what a field's precision and scale keep, are not simulated. Each element of a decimal array is checked the same way. Nothing is derived from other fields. A JSON number's exact plain form may add at most 32 zeros to its significant digits (1e32is kept whole): past that, a decimal parameter gets422before anything changes, and a number inside an untyped object keeps its exact value in exponent form (0.1e100001), so no number's text grows much beyond the bytes that carried it. - Parameters are checked against the schema before anything changes:
types, 32-bit ranges, enumerated values, dates and date-times, and
arrays of them. A parameter that is not one of the record's fields is
accepted only when its name marks a secret (such as
password,tokenorprivate_key): it is type-checked, then never stored, returned or journaled. Any other one gets501. - An empty string sent for an array parameter is an empty array, as the
API's parameter coercion reads an array of strings, numbers or objects
(
[Hash]), such as an automation'simport_urls. Form field sets'form_fieldsis the exception: the API declares it as nested JSON objects, whose blank string (any blank line) is no value, so it is kept as an explicitnullis, and whose other strings are parsed as JSON: an encoded array of objects, or one object (wrapped in an array), is kept as the same array sent directly, numbers included, and"[]"and"null"are what they encode. An encoded value that is malformed, a scalar or holds a member that is not an object gets422bad-request(form_fields is invalid), unlike a malformed request body (bad-request/invalid-body); an object sent directly is not wrapped. Any other string, and a member of the wrong type, gets422. Only requests are read this way, with the fixture records a resource's create makes; fixture responses and the other fixtures are checked exactly as given. What Files.com's form field model then does with the fields (their ids, defaults and rules) is not simulated. - A required parameter sent
nullis there, so the API's parameter validation admits it; only one left out is422bad-request(name is missing). For a share group's create, the model's own validations then decide, as Files.com's ShareGroup does: a null or blankname, and null or emptymembers, fail the save with422processing-failure/model-save-errorand the model-error body (error,model_errorsby field,model_error_keyssuch asmembers_cannot_be_empty,errors), and nothing is kept. Anamesent null never hides another parameter's framework error. The member models and the user, site and workspace checks behind a save are not simulated, nor is a share group update's. Other resources still refuse a required parameter sent null as missing, since what their models do is not simulated. - List parameters other than
cursorandper_page(filters, sorts,ids,search) get501. Lists page by ID like users. - A resource whose entity has no
idand that is only listed, such as the history and log lists, is filled by fixtures and paged in fixture order. - A list declared to answer one entity instead of an array (in the
schemas here, only the decimal-compat fixture's
GET /foos, whichspec/fixtures/swagger_decimal_compat_new.jsondeclares) answers the one record holding exactly the values its parameters send, a decimal's text and an array's elements in order included; none gets404, and more than one501. This is the simulator's reading of a synthetic schema's shape for the decimal-array catalogue lane, not the behavior of any Files.com endpoint. - The site is a singleton:
GET /api/rest/v1/sitereturns what fixtures andPATCH /api/rest/v1/siteset, checked with the update rules. - A required create parameter that names the parent a record belongs to
(
path, or a*_idthat is not one of the record's fields, such as a bundle recipient'sbundle_idor a comment'spath) is kept as the record's structural scope. It is never returned and never checked against the parent: nothing verifies that bundle 7 ordocs/a.txtexists. A list of such a resource requires its scope and returns only that scope's records, and its cursors are valid only for that same scope (and key-field selection); another one gets422bad-request/invalid-cursor. A reset fixture gives a record the scope its create would have carried with_scope, as in{"name": "legal", "_scope": {"path": "contracts"}}. - A required list parameter that names the parent scopes that list the
same way. An automation run's
automation_idis one of its fields, so the list answers the runs holding it. A history export result'shistory_export_idand an action notification export result'saction_notification_export_idare not fields: fixtures set them with_scope, checked like the list parameter, and they are never returned. Left out, the parameter gets422bad-request. - Records without an
id(bundle and inbox recipients) are kept in the order they were created. Group memberships have noideither:PATCHandDELETE /api/rest/v1/group_users/{id}find the membership by thegroup_idanduser_idthey require, and the{id}in the path selects nothing, because the GroupUser object has noidto compare it with (a recorded route-key decision). A delete-only resource (partner sites) holds what fixtures give it.
These lists return the records of one scope, by an explicit rule rather
than general filtering: the behaviors of a folder
(/behaviors/folders/{path}; with ancestor_behaviors, also those of the
folders above it), comments on a file (/file_comments/files/{path}),
requests of a folder (/requests/folders/{path}), history of a file,
folder or user and login history (/history/...), and the metadata
categories that apply to a path (/metadata_categories/list_by_path/{path},
assigned by fixtures with _scope, since folder behaviors the simulator
does not evaluate decide it on Files.com). Their other parameters get
501.
/api/rest/v1/styles/{path} is keyed by path: GET finds the style, the
first PATCH creates it (with an id from 1) and later ones change it,
and DELETE removes it. The path is never checked against files and
folders. A style's file is a file parameter.
Read-only operations whose answer depends on configuration or providers
the simulator does not hold (site usage, the automation authoring schema,
an automation run's node, supported holiday regions, reserved IP
addresses, a remote server's agent nodes and configuration file) answer
only from a reset fixture under fixtures.responses, checked against the
object's fields, id included, which is kept exactly as given: one
answer, a list (paged like records), or answers keyed by the operation's
required parameters joined with /, such as
{"remote_servers.find_configuration_file": {"7": {"id": 7}}}. The route's
values name the answer, whatever a query or body copy of the same name
says. A required query parameter left out, such as a run node's
node_id, gets 422 bad-request (node_id is missing) before the
fixture is consulted, as the API's parameter validation answers it. Sent
null or empty it passes that validation: an empty value is part of the
key, so {"automation_runs.find_node": {"3/": …}} answers run 3 with an
empty node_id, and a null names no key. Without a fixture they get
501, and a key with no answer gets 404. The simulator never invents
their content.
GET /api/rest/v1/partner_site_requests/find_by_pairing_key, whose schema
declares only 204 with no body, is a lookup: its fixture lists the
synthetic keys that exist, as in
{"partner_site_requests.find_by_pairing_key": ["key-a", "key-b"]}, and a
request gets 204 for one of them and 404 for any other, a
pairing_key sent null or empty included, since listed keys are
non-empty strings; left out, it gets 422 bad-request first. That is
existence only: nothing of real partner pairing, approval or
authentication is simulated. It is the fixture's rule, not Files.com's:
the service's lookup presents the partner site request it finds, and
approval or rejection clears a request's key, so a null key can find
one; what it answers for a key it does not find, and whether an answer
is the 204 the schema declares, is not established here.
A parameter the schema types as a file (avatar_file, attachment_file,
a style's file) is accepted in the same request as its companion
values, either as the JSON file object Files.com takes,
{"encoded_content": BASE64, "filename": ..., "type": ...}, or as a part
of a multipart/form-data request whose other parts carry the companions
(nested ones as value[url]). Its bytes are decoded within the body
limit, checked and discarded: the journal's attachments lists each
one's parameter, filename exactly as sent (and the raw filename of a
multipart header), content type, size, SHA-256 and encoding, never its
bytes. Base64 that does not decode gets 422. Multipart cannot carry
null or typed nested values (an empty part for an array parameter is an
empty array, as above); send JSON when they matter.
The readiness response names each record operation with its Swagger
operation ID. GET /__files_mock/v1/inventory returns every operation of
the Swagger document the server was generated from, with its disposition:
record, singleton, scoped-list, path-record, fixture-response,
specialized (users, files, folders, file migrations, locks), real-only (its
result depends on who is authenticated, such as sessions and the current
API key) or gap. A gap names its gap_kind and reason:
action-semantics (the named owner must decide the operation's simulated
contract), real-or-provider (outbound delivery or provider behavior,
real lane only), excluded-auth (authentication state, excluded),
incomplete-schema (a lock operation of a Swagger document that lacks
another lock operation or the Lock object, which the simulator needs to
simulate any of them) or shape.
An operation with a recorded reason is refused even when its shape looks
like a record's, such as POST /webhook_tests, which sends a request.
GET /api/rest/v1/users returns users in ID order, which is the order
they were created. per_page must be a whole number from 1 to 10000 and
defaults to 1000. When more users remain, the response carries the same
cursor in both the X-Files-Cursor and X-Files-Cursor-Next headers;
pass it back as cursor, with the same per_page, to get the next page.
The last page has neither header.
Cursors are opaque. A cursor is accepted only by the simulator process
that issued it, with the same per_page, until the next reset; anything
else returns 422 with the type bad-request/invalid-cursor.
A cursor continues after the last user it returned. Users created during a traversal appear on later pages, users deleted before they are reached are skipped, and no user is returned twice.
With the reset profile {"list_cursors": "special-characters"}, every
cursor (records, scoped lists, folders and fixture responses) starts with
2:a+b &x=y%25#: a space, +, &, =, a percent escape and #. A
client must send it back exactly, encoded once; one decoded, or encoded
again, gets 422 bad-request/invalid-cursor. Cursors travel in headers,
so they stay ASCII; a fixture response keyed by a query value (such as an
automation run node's node_id) checks Unicode as well.
Filtering, sorting and search parameters (sort_by, filter,
filter_gt, filter_prefix, ids, search, and the like) return
501 rather than an unfiltered list.
Simulation mode keeps files and their bytes in memory, so a test can upload a file with a Files.com SDK and download exactly the same bytes, whole or as a single byte range.
A file path is a name in the simulator's own namespace: nothing is read
from or written to the machine running the server. Paths keep their
spaces, Unicode characters and percent signs. The path in a URL is
decoded exactly once, so /api/rest/v1/files/folder/a%252Fb.txt names
the file a%2Fb.txt in folder, and the Go SDK (which sends a path's
slashes as they are) and the Python SDK (which encodes them as %2F)
name the same file.
Paths are stored and returned exactly as sent. To find paths the
Files.com API would treat differently, the simulator compares them as
the API does, using the comparison map in shared/path_comparison.json
(version 1, for MySQL's utf8mb4_0900_ai_ci), which comes from the
Files.com server. It uses the comparison only to refuse paths it does
not model; see
Differences from the Files.com API.
A required parameter left out of a file action, such as a copy's
destination, gets 422 bad-request with the message
destination is missing, as the API's parameter validation (Grape)
answers it. That validation checks only that the parameter is there: one
sent as null or empty passes it and is judged by the action after the
path rules below. A file action's destination, or an unzip's path, sent
that way names the site root, as the API's path and destination helpers
normalize it (see Copying and moving and
ZIP files).
Every file, folder and lock route checks its path as the API's path
helper does, after the request's declared parameters (so a value of the
wrong type, or a copy, move or unzip without a destination, is answered
first):
- A path with a folder name ending in whitespace (a space, a tab or
another ASCII whitespace character) gets
422bad-request/path-cannot-have-trailing-whitespace. - A path holding a zero-width space (U+200B) anywhere then gets
422bad-request/invalid-path. No other invisible character is refused.
On the folders endpoints (POST and GET /api/rest/v1/folders/{path})
every name in the path is a folder's, so the last one is checked too. On
every other route the last name is the file's or lock's own and may end in
whitespace. So docs/notes.txt (with a trailing space) can be uploaded,
downloaded and locked, while docs /notes.txt and
POST /api/rest/v1/folders/docs%20 are refused. An unzip's path is
checked like a route's path; destinations follow the API's destination
rule instead (see Copying and moving). A path in a
form the API would rewrite still gets 501 first, and a folder name
ending in whitespace is refused before a name the simulator refuses as
ambiguous.
The SDKs' upload methods take these steps for you:
POST /api/rest/v1/file_actions/begin_upload/{path}starts an upload and returns200with an array of one upload part: itsref,part_numberand theupload_urito send that part's bytes to. Send therefand the nextpartnumber to get the next part's URL. Withparts: Nthe array holds N consecutive parts of the same upload, from part 1 for a new upload or frompartwith aref(which may repeat parts already issued; they keep the size they were issued with).partsmust be a whole number from 1, and the last part it asks for must be within the simulator's 64 parts, or it gets422or409before any folder, upload or part changes.PUTeach part's raw bytes to itsupload_uri, with any content type or none. The response is200with anETagheader: the SHA-256 of the part's bytes, in quotes. Sending a part again with the same bytes returns the same ETag; different bytes for a stored part get501.POST /api/rest/v1/files/{path}withaction: "end", theref, andetagslisting every part as{"etag": ..., "part": ...}. This publishes the file in one step and returns201and the file, or200when it replaces an existing file. A sequential upload of known size may leaveetagsout and sendsizeinstead, as the .NET SDK does; the parts the upload received then become the file.
size, when beginning or finalizing an upload, is a byte count: a whole
number from 0 below 2^63, sent as a number or a string of digits. The
production schema documents it as int32, but the API reads it as an
Integer byte count, so the simulator does not hold it to 32 bits. Every
other whole-number parameter, part numbers among them, keeps its
schema's range.
Parts are joined in part number order, whatever order they arrived or
were listed in, and a part number may be sent as a string ("2"), as
the Go SDK does. Before anything is published, finalizing checks that
the listed parts are numbered 1 to N with no gaps or repeats, that each
was uploaded with the listed ETag, that no uploaded part is left out,
and, when size is sent, that the parts add up to it. A failed check
changes nothing: the upload stays open so it can be finalized again,
and an existing file keeps its bytes. A finalized upload's ref is no
longer valid, so a repeated finalize gets 404.
| Problem when finalizing | Status | Type |
|---|---|---|
No ref |
422 | bad-request/request-params-required |
An unknown ref, or one for another path |
404 | not-found/file-upload-not-found |
| No parts, a listed part that was not uploaded, or a wrong ETag | 422 | processing-failure/file-not-uploaded |
| Part numbers with a gap or a repeat, or an uploaded part left out | 422 | bad-request/invalid-etags |
A size the parts do not add up to |
422 | bad-request/request-params-invalid |
| A file where a folder is | 422 | processing-failure/destination-exists |
Without etags, finalizing uses the parts the upload has received, in
part number order, as Files.com's own storage finds an upload's parts.
The simulator does this only for a sequential upload of known size:
size must be sent and the received parts must be numbered 1 to N. A
part still being received does not count. Without size, or with a gap
in the received parts, finalizing gets 501, and with no part received
it gets 422 processing-failure/file-not-uploaded. Sent etags are
always checked as above and never replaced by the received parts:
etags: [] gets 422 and etags: null gets 501.
An empty file is an upload of one empty part. provided_mtime accepts a
time with a UTC offset, a time without one (as the Python SDK sends it,
read as UTC), a UTC time in the fixed form 2026-09-24 00:00:00Z (as
the .NET SDK sends it), or Ruby's 2026-09-24 07:11:22 -0700 and
2026-09-24 14:11:22 UTC (as the Ruby SDK sends it). Only the ISO form
may leave out the offset. It is returned in UTC to whole seconds.
The upload parts advertise parallel_parts: false, retry_parts: true
and a partsize of 5 MiB, or FILES_MOCK_MAX_BODY_BYTES when that is
smaller. These tell a client how to upload; the simulator does not
enforce them. It accepts parts sent at the same time or out of order and
joins them by part number when the upload is finalized, so a passing
test does not show that a client sends one part at a time. SDKs may send
other part sizes, and the simulator accepts them:
the Go SDK sends up to 5 MiB per part whatever partsize says, while the
Python SDK follows partsize. An upload that exactly fills its last part
may finish without an empty terminal part. The simulator also accepts an
empty terminal part from clients that send one. Every part body must fit in
FILES_MOCK_MAX_BODY_BYTES, so raise it to at least 5 MiB, for example
FILES_MOCK_MAX_BODY_BYTES=8388608, before uploading files over 1 MiB
with the Go SDK.
GET /api/rest/v1/files/{path} returns the file with a download_uri,
and GET /api/rest/v1/file_actions/metadata/{path} returns it without
one. With action=stat, GET /api/rest/v1/files/{path} also returns
the file without a download_uri, and it describes folders too:
/api/rest/v1/files/%2F?action=stat is the root. Downloading a folder
gets 422 with the type bad-request/cannot-download-directory, and a
path that does not exist gets 404 with the type not-found.
Sent with Accept-Language: es, as the Ruby SDK does after
Files.language = "es", GET /api/rest/v1/file_actions/metadata/{path}
for a path that does not exist answers with the message a Files.com site
sent in Spanish, No se ha encontrado. Esto puede estar relacionado con tus permisos., and the same status, title and type. That is the simulator's
only translation: any other Accept-Language value, and every other
operation and error, gets the English message.
GET on the download_uri returns 200 with the file's bytes,
Content-Length, Content-Type: application/octet-stream,
Accept-Ranges: bytes and an ETag. With a Range header naming one
byte range (bytes=7-99, bytes=7- or bytes=-100), it returns 206
with just those bytes and a Content-Range header. A range that ends
past the end of the file is shortened to it, and one that starts past
the end gets 416 with Content-Range: bytes */{size}. Any other
Range value, such as several ranges, is ignored and the whole file is
sent, as HTTP allows; so is every range on an empty file. HEAD is not
simulated.
A download URL names one version of a file. Once the file is replaced
or deleted, its old download URLs get 409 with the type
download_source_changed, and the Go SDK then requests a new URL. A
download already in progress finishes with the bytes it started with.
Files Sync Worker, the storage proxy behind some Files.com download URLs,
gave each download it began streaming an X-Files-Download-Request-Id
header (with the transfer's X-Files-Download-Id) and answered GET on
the download URL joined with that ID with the request's status (its
GET /download/:code/:request_id at commit 9073d2bc). The Go SDK was
written against it: it joins the ID to the URL (url.JoinPath) to explain
a failed download or to confirm a file whose size it could not trust. A
reset selects this protocol with download.request_status (see Transfer
profiles); without it, downloads carry neither header
and that path is not simulated (501), as before. Whether any Files.com
deployment still serves this protocol is not established.
With it selected, each 200 or 206 storage download response gets a
new ID. A response that fails before it streams gets none, as the
captured producer source gives none to such a response; what a response
that failed after it began streaming carried is not established.
header_on_failure: true gives a failed response an ID too; that is the
simulator's choice, for reaching a client's handling of an ID on a failed
response. With header_on_failure: false (the default), a fault's own
answer gets no ID. With it set to true, non-2xx fault answers,
including expired_url and redirects, get an ID; html_page
returns 200 and still gets none.
The ID answers the status the reset chose, completed by default; the
simulator does not run a transfer lifecycle. A request it holds answers
200 in the producer's envelope, with http_code spelled with an
underscore and its null members kept:
{"type": null, "title": null, "error": null, "http_code": 200, "errors": null,
"data": {"file_download_id": "…", "request_id": "…", "id": "…", "type": "file_download",
"bytes_transferred": 6, "file_transfer_id": "…", "status": "completed", "method": "get"}}status |
type and error |
bytes_transferred |
|---|---|---|
completed |
null |
The bytes of the stored version the response was made from: the whole file, or the range. |
started |
null |
null, as for a request still streaming. |
failed, error |
download_error (the producer's fallback type) and a message saying the profile chose the status |
As for completed; null for an ID that header_on_failure gave a failed response. |
A request the simulator does not hold, and a download URL it does not
hold, get the producer's 404: type download_request_not_found,
error "Download request expired or not found. Please try this transfer
again.", http_code 404, and data naming the URL's download and the
request found by its ID (null when none is). A request is not held when
its ID was never issued, is malformed, is another simulator's, is from
before the latest reset (the producer lost its requests when it
restarted), or was displaced by newer requests. A download URL is held
only when files.download issued it in this reset, as the producer held
the transfer each download URL named: a well-formed URL for a version
never made, or for one whose URL was never asked for, is not held. An
issued URL stays held when its file is replaced. A reset keeps the newest
FILES_MOCK_MAX_RECORDS requests and the newest FILES_MOCK_MAX_RECORDS
issued URLs, and downloads go on after that. As the producer found a
request by its ID alone, an ID also answers at another held download URL
of the reset, and its data then names both (file_download_id and
file_transfer_id differ). The query after
the joined path is not checked, as the producer skipped its expiry check
for a status lookup; the Go SDK sends the download URL's own. Fault rules
may target transfers.download_status like any transfer operation.
bytes_transferred counts what the simulator read from the stored version
for the response, as the producer counted what it read from the remote. A
truncate, short_body, excess_body or stall rule changes what
reaches the client (the journal's sent_bytes), not this count, so compare
it with what you received, as the Go SDK does for an untrusted size.
For example, against a server on port 4041 ("status": "failed" instead
answers with "type":"download_error" and its message):
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:4041/__files_mock/v1/reset -H 'Content-Type: application/json' \
-d '{"profile": {"download": {"request_status": {"status": "completed"}}}, "fixtures": {"files": [{"path": "f.txt", "text": "abcdef"}]}}' # 200
url=$(curl -s localhost:4041/api/rest/v1/files/f.txt | ruby -rjson -e 'puts JSON.parse($stdin.read)["download_uri"]')
id=$(curl -s -o /dev/null -D - "$url" | awk 'tolower($1) == "x-files-download-request-id:" { print $2 }' | tr -d '\r')
curl -s "$url/$id"; echo # {"type":null,"title":null,"error":null,"http_code":200,"errors":null,"data":{…,"bytes_transferred":6,…,"status":"completed","method":"get"}}The status data leaves out what the producer also held: started_at and
completed_at, whose wire encoding was not established, and its remote
server's log context (site_id, remote_server_id, path,
server_type, client_ip). How long the producer kept a request, how its
status moved through failures, cancellation and ranges, and which errors it
stored are not simulated.
Folders are records, as on Files.com: POST /api/rest/v1/folders/{path}
creates one, it is listed and described like a file (with the type
directory and no size), it stays after its last file is deleted, and
it is deleted only when it is empty. The root folder always exists; its
path is empty.
By default the simulator behaves like a site whose "always create parent
folders" setting is on: every write, whether creating a folder, beginning
an upload or finalizing one, creates the folders missing above its path,
and mkdir_parents has no further effect, even when it is false. The
reset profile {"site_policy": {"always_mkdir_parents": false}} turns the
setting off, as it is by default on a Files.com site: then creating a
folder, beginning an upload or finalizing one whose parent folder is
missing gets 404 with the type not-found unless the request sends
mkdir_parents: true, which creates the missing folders, as the API's
folder lookup does. Unzip creates its destination folder either way, as
the API does; a copy or move into missing parent folders gets 501
under that profile, because how Files.com treats it there is not
simulated. The readiness response reports the policy in effect
(namespace.site_policy). A write that cannot finish creates none of the
folders it would have needed.
Creating a folder is exclusive: a name that a file or folder already
holds gets 422 with the type processing-failure/destination-exists,
and the existing entry is never adopted. A file cannot be a folder's
parent (422, bad-request/folder-must-not-be-a-file).
GET /api/rest/v1/folders/{path} lists a folder's files and folders in
path order, compared as the API compares paths. per_page must be a
whole number from 1 to 10000 and defaults to 1000. When more entries
remain, the response carries the same cursor in both the
X-Files-Cursor and X-Files-Cursor-Next headers; pass it back as
cursor, with the same per_page, for the next page. The last page has
neither header. A cursor continues after the last entry it returned, so
entries created later appear on later pages when they sort after it,
entries deleted before they are reached are skipped, and none is
returned twice. A cursor is accepted only for the folder, per_page,
simulator process and reset epoch it was issued for; anything else gets
422 with the type bad-request/invalid-cursor. Listing a folder that
does not exist gets 404.
with_previews (when the Swagger document declares it) asks for each
file's preview, which the simulator does not hold. Folders have no
previews, so a page of folders is answered exactly as without it. A page
that would hold a file gets 501 with the type simulation/not-supported
before a cursor is issued for it; the same request without
with_previews, or with it false, lists that page.
DELETE /api/rest/v1/files/{path} deletes one file, or one empty folder,
and returns 204. A folder holding anything gets 422 with the type
processing-failure/folder-not-empty unless recursive=true, which
deletes everything strictly inside it (foo/..., never foo2) and then
the folder, releasing the bytes of every file it held. The root is never
deleted (422 without recursive, 501 with it). Look the path up
afterwards to see that it is gone. The boolean
parameters of this operation, GET /files/{path},
GET /file_actions/metadata/{path} and GET /folders/{path} may also be
True or False, as .NET writes booleans; this applies to those
operations' parameters wherever the request carries them, and to no
other operation.
PATCH /api/rest/v1/files/{path} sets a file's or folder's
custom_metadata, priority_color and provided_mtime, and the file or
folder is returned with them. They stay with the path when it is moved
and are dropped when a new version replaces the file or the path is
deleted. A folder moved or copied onto an existing folder (a merge) leaves
the destination's own metadata; a move still ends the source's, so a new
entry at the old path starts without it, and a copy keeps it on the
source. A path that does not exist gets 404.
An update replaces the whole custom_metadata object, and a null one
clears it to {}. Its values must be strings or null: a number, a
boolean, an object or an array gets 422 bad-request before the path
is looked up, and nothing is converted to text. Empty strings and null
values are kept. As the API's model refuses to save it, an object with
more than 32 keys, a key longer than 256 characters or a non-null value
longer than 1024 (counted in characters, not bytes) gets 422
processing-failure/model-save-error listing every failure in order
(model_error_keys {"custom_metadata": ["key_too_long", "value_too_long", "too_many_keys"]} for each). A refused update changes
nothing it sent.
The SDKs' external-destination helpers upload, copy and move to paths
such as _/RemoteServers/7/in/x.bin, _/Snapshots/8/... and
_/Sites/9/.... Here they are ordinary paths in the simulator's
namespace, and each request's journal entry records the store it names
as destination_scope (remote_server, snapshot or child_site, with
its ID). No remote server, snapshot or child site is involved.
POST /api/rest/v1/file_actions/copy/{path} and
POST /api/rest/v1/file_actions/move/{path} take destination, and
overwrite (both) and structure (copy). They copy or move a file, or a
folder with everything strictly inside it, keeping exact bytes and the
original spelling of every path, and answer 201 with
{"status": "completed"}, as Files.com answers a move it finishes at
once. A copy is a new file whose bytes count against
FILES_MOCK_MAX_TRANSFER_BYTES; a move keeps the same file, so its old
download URLs still send it. Missing parent folders of the destination
are created.
- An existing destination gets
422with the typeprocessing-failure/destination-exists("The destination exists.") unlessoverwriteistrue: then files replace files and folders merge into folders. Overwriting a file with a folder or a folder with a file, copying or moving a folder into itself or onto a folder that holds it, copying or moving the root,structurefor a file andcopy_behaviors: trueget501. - A
destinationsentnullor empty is the site root, which always exists. Withoutoverwritethat is422processing-failure/destination-exists. Withoverwrite, Files.com enqueues the action, and theFileMigrationit creates fails its destination presence validation; the API answers that with its catch-all500,{"error": "Internal server error, please contact support or the person who created your account.", "http-code": "500"}and noX-Files-Error-Class. Nothing changes and nothing is enqueued. This follows the Files.com source (its handlers outside tests); it has not been observed on a live site. structure: truecopies a folder's folders only.- A folder name in the destination (any name but the last) that ends in
whitespace gets
422with the typebad-request/path-cannot-have-trailing-whitespace, as the API's destination rule refuses it; unzip applies the same rule to the folders above its destination folder. A last name ending in whitespace is not refused by that rule. - Every check happens before anything changes, including the record and byte limits.
With the reset profile {"file_actions": "pending"}, a copy or move
answers {"status": "pending", "file_migration_id": N} instead and
changes nothing yet. GET /api/rest/v1/file_migrations/{N} then reports
processing the first time it is polled and, the next time, applies the
action in one step against the state at that moment: completed with
files_moved, or failed with the reason in failure_message and
nothing changed. As Files.com presents a FileMigration, files_moved
is 0 until the migration has processed anything and files_total is
always 0.
When the Swagger document declares them (the local export b8129459 and the production schema do), a stored ZIP file can be listed and extracted as Files.com does it, following the Rails file actions controller and the integration worker's ZIP reader:
GET /api/rest/v1/file_actions/zip_list/{path}answers each entry'spathand uncompressedsize, in the order of the archive's central directory, folder entries (dir/) included. A folder gets422bad-request/folders-not-allowed, a missing path404, and a file the reader cannot read (not a ZIP, an encrypted entry, a broken directory, more than 10000 entries)422processing-failure/invalid-zip-file.POST /api/rest/v1/file_actions/unziptakespath(the ZIP file),destination(a folder), andfilenameandoverwrite. A missing or folderpathis refused as above, and apathsentnullor empty is the site root, a folder, so it getsbad-request/folders-not-allowedbefore anything is made. The destination folder and its parents are created with the request; adestinationsentnullor empty is the root folder, which the extraction fills. It always answers201with{"status": "pending", "file_migration_id": N}, whatever thefile_actionsprofile, because Files.com extracts in aFileMigration. Polling the migration to completion extracts every file entry (or onlyfilename) to its path under the destination, creating the folders its path names; folder entries create nothing themselves. It fails with Files.com's message, changing nothing, for an entry path that starts with/or has a..segment ("Invalid ZIP entry path: …"), afilenamethe archive does not hold ("File not found inside ZIP: …"), an existing file withoutoverwrite("The destination exists."), an unreadable archive, or a compression method other than stored or deflate. Files.com extracts entries one at a time and may keep those it extracted before one fails; the simulator checks the whole extraction first, which is not a claim about what Files.com keeps.- An extraction is admitted against
FILES_MOCK_MAX_RECORDSandFILES_MOCK_MAX_TRANSFER_BYTESby the entries' recorded uncompressed sizes before any entry is decompressed; one the limits refuse fails its migration withsimulation/limit-exceededhaving expanded nothing, and an archive that records more than it holds is counted by what it records. A replaced file's bytes are not credited against the limit. No entry's data grows past its recorded size: decompression stops within one zlib output chunk (16 KiB) of it, and the entry is then not simulated, as below. - Not simulated (
501): ZIP64 archives, entry names that are neither marked UTF-8 nor plain ASCII, an entry whose data does not match its recorded size, and extracting onto a folder. CRC-32s are not checked, as the worker does not check them. - The Go SDK's ZIP batch downloads (
/zip_downloads) are not simulated; that route and its consumers belong to the Go/CLI owner.
When the Swagger document declares it (the local export b8129459 does),
POST /api/rest/v1/file_actions/zip saves a ZIP of files and folders as
a file, following the Rails file actions controller, its ZipDownload
and FileMigration#perform_zip, and files-protocol-server's ZIP stream
(current repository sources, not a deployed service's identity):
- It takes
paths(the selection),destination(the file to save) andoverwrite, and checks them in the controller's order. Apathsordestinationleft out is422bad-request(paths is missing), the API's parameter validation;pathssentnull,[]or""(an empty array to the API's coercion) is422bad-request/request-params-required, the controller's own check. Adestinationsentnullor empty is the site root: after the selection, its lookups and the no-files check below, theFileMigrationfails its destination presence validation, which the API answers with the catch-all500described under Copying and moving; no migration is made. Exact duplicates are removed, keeping the first; paths are compared exactly as sent, and a path in another spelling is refused as not simulated (see Differences from the Files.com API). A selected path that does not exist is404not-found, checked before the destination. Then comes the destination: a folder name in it (any name but the last) ending in whitespace is422bad-request/path-cannot-have-trailing-whitespace, and a destination inside a file is422bad-request/folder-must-not-be-a-file. None of these refusals changes anything. Then the destination's missing parent folders are created, and only then must the selection hold a file (an empty file counts; a folder whose tree holds none does not). A selection that does not is422processing-failure/model-save-error(Files.com's type and status; its validation message is not modeled). The folders already created stay, as on Files.com, and no migration is made. Otherwise the request answers201with{"status": "pending", "file_migration_id": N}, whatever thefile_actionsprofile: accepting the request is not a promise that the ZIP will be saved. The migration'spathis the first requested path, not the first in sorted order. - Polling the migration to completion reads the selection then: the
current version of each selected file and the files each selected
folder holds at that moment. It makes the archive from them and saves it
as the destination, creating the destination's parent folders again if
they were deleted meanwhile. A selected file is
named by its own name (
a/file.txtbecomesfile.txt); each file in a selected folder, at any depth, by the folder's name and its path inside it (selectingx/agivesa/...). There are no folder entries, so an empty folder is left out and an empty file kept. The selection is taken in sorted order, and a repeated name is numbered as the ZIP stream numbers it, counting every name the archive has used:file (1).txt,file (2).txt,README (1)for a name without an extension, andfile (1) (1).txtfor a later file already namedfile (1).txt. Saving the archive is one processed operation, so a completed ZIP reportsfiles_moved1, whatever its number of entries (the journal records that number asentries). - The destination is checked when the migration runs: an existing file
fails it ("The destination exists.") unless
overwriteis true, and is then replaced; a file saved there after the request counts too. Files.com deletes the file an overwrite replaces before its ZIP stream starts, so the simulator leaves that old file out of the new archive, even when the destination is inside a selected folder. Separately, the simulator chooses the archive's entries before it saves the new archive, so a new destination inside a selected folder is not one of them either. When Files.com's receiving upload makes a new destination visible, relative to the stream reading the selection, is not established by the sources the simulator follows: that case is the simulator's own behavior, not a claim of parity with Files.com. Files.com streams the archive into the destination and does not promise to keep the old file if that fails; the simulator builds the archive whole first, which is not a claim that replacement is atomic. - Each entry is deflated, or stored when that is not smaller, with its CRC-32 and sizes in its local header and its name marked UTF-8. Files.com's compression method and level are deployment settings, so neither they nor the archive's bytes or entry order are a contract; its entry names and contents are. The saved archive lists and extracts as any stored ZIP does (above).
- A ZIP is admitted against
FILES_MOCK_MAX_TRANSFER_BYTESby the most bytes it can take (its files' sizes and headers) before it is built, and againstFILES_MOCK_MAX_RECORDSfor its file and the parents the request creates; one past a limit fails withsimulation/limit-exceeded, saving nothing. It holds at most 10000 entries, what the reader lists. - Not simulated (
501, or a failed migration with that message): a numbered name that repeats another entry's name, a repeated name the stream cannot number (such as.env, which has a dot and no extension), replacing a folder, and an archive needing ZIP64; and, when the migration runs, a selected path that no longer exists, a selection naming the file its overwrite removes, a selection that no longer holds a file, and a destination that is now inside a file. What Files.com does when its stream cannot read a file after the archive has started (it can end with an incomplete archive), and how that ends the migration, is not established, and neither are remote mounts, workspaces, bundles, permissions, locks or names outside UTF-8.
A reset's profile chooses how uploads and downloads behave until the
next reset (see Reset and fixtures).
The profiles are simulation policies, never claims about a particular
real site or provider:
| Setting | Values | Effect |
|---|---|---|
upload.mode |
legacy (default), serial, parallel |
legacy keeps the advertisement described above without checking it. serial advertises parallel_parts: false and admits one part of an upload at a time, in order; parallel advertises true and admits parts in any order. Both check the settings below; a violation gets 409 or 422 with the type simulation/profile-violation. |
upload.retry_parts |
true (default), false |
With false, a second send of a part is refused; restart the upload. |
upload.partsize |
bytes | The part size each part is issued with (partsize, and next_partsize for the part after it). It is a hint: parts of other sizes, larger or smaller, are accepted, each within FILES_MOCK_MAX_BODY_BYTES, and parallel_parts: false asks only for one part at a time, in order. Only variable_part_limits bounds part sizes further. |
upload.partsize_changes |
[{"from_part": N, "partsize": bytes}, ...] |
From part N on (N at least 2, increasing), parts are issued with the new size and next_partsize announces it. Parts already issued keep theirs. |
upload.variable_part_limits |
min_nonfinal_bytes, max_part_bytes, max_parts, max_file_bytes |
Unequal parts within these limits. An upload begun without size needs its final size and every part's etags to finish. |
upload.part_offset_query |
true, false |
Parts may send ?part_offset=N: every send of a part must name the same offset, and at finalize the offsets must match the sizes of the parts before. |
upload.upload_target_class |
s3, agent_fiw, generic |
Advertised as is. |
upload.max_concurrent_parts, upload.throttle_retry_after |
1–64, seconds | Parallel only: at most this many parts hold a place at once across the uploads not yet finalized, each from its admission, before its body is read, until the simulator has applied or refused it and made its response; more get 503 with Retry-After. The journal's upload_parts reports the count. |
signed_urls.lifetime |
seconds | Upload and download URLs carry a synthetic AWS SigV4-style presigned query with a sentinel credential and a signature over the exact query; a URL sent with any byte of its query changed gets 403 SignatureDoesNotMatch (XML), as presigned storage URLs do. The lifetime is advertised, not enforced; use expired_url faults to expire URLs. Not combined with part_offset_query. |
download.request_status |
{"status": "completed", "started", "failed" or "error", "header_on_failure": false or true} |
The historical Files Sync Worker protocol: each successful storage download response carries X-Files-Download-Request-Id and X-Files-Download-Id (a failed one too with header_on_failure, which the captured producer source does not do for a response that fails before it streams), and the download URL joined with the ID answers the chosen status in the producer's envelope; see Download request status. Absent by default. |
download.size |
sent (default), withheld |
With withheld, a whole-file 200 has no Content-Length (Puma sends it chunked to an HTTP/1.1 client), as the historical producer answered for a remote whose size cannot be trusted, and a 206 has Content-Range: bytes a-b/* (the simulator's choice; the producer's range header for that case was not captured). Clients such as the Go SDK then treat the size as untrusted. |
download.identity |
absent (default), contract_v1 |
With contract_v1, GET /api/rest/v1/files/{path} also takes with_download_identity (which may be True or False, as the Python SDK sends it) and expected_download_identity and returns download_identity (version, token, size, required_headers with a strong If-Match), following the adopted download identity contract. The URL then requires that If-Match; a changed file, even one of the same size, gets 412 download_source_changed, and renewing with the token returns the same identity while the file is unchanged. A token that is not fdi1. followed by URL-safe Base64 of at most 4096 bytes gets 400; one that names no current version (a changed file, or one from before a reset) gets 422 processing-failure/download-identity-unavailable. The local Rails export (b8129459) declares both parameters and the production pin does not: from a schema that declares them, absent acts as a site with the setting off (with_download_identity gets an ordinary download, and an expected token gets 422). |
variable_part_limits, part_offset_query and upload_target_class are
FileUploadPart fields of the combined adaptive schema; a server generated
from a schema without them refuses a profile that sets them.
Separately from that advertised hint, each part has an intended
length: its actual length, fixed the first moment it is known, from a
declared Content-Length or the bytes a first complete send delivered.
Every later send of the part, a retry after a fault or a send to a
renewed URL, must hold exactly that many bytes (409 otherwise). A send whose length is unknown (no
Content-Length) is measured once read, and one that ends early (an
aborted body) binds nothing and stores nothing.
The upload_uri and download_uri values are URLs under
/__files_mock/transfer/. Use them as they are: they are not Files.com
API paths, and they work only on the simulator process that issued them,
until its next reset. An upload URL works until the upload is finalized;
its expires time, 15 minutes after it was issued, is not enforced.
The URLs start with the address and port the request arrived on, such as
http://127.0.0.1:4041. They never reflect the request's Host header.
When your tests reach the server at a different address, such as a port
published from a Docker container, set FILES_MOCK_TRANSFER_ORIGIN to
that origin, for example http://127.0.0.1:40410. The server refuses to
start if it is not an http or https origin without a path.
Some SDK download helpers accept only https URLs. Behind a TLS bind the
URLs use https:
FILES_MOCK_MODE=simulation bundle exec puma -b 'ssl://127.0.0.1:4443?key=key.pem&cert=cert.pem&verify_mode=none'Give your client the certificate as a trusted CA (for Node.js,
NODE_EXTRA_CA_CERTS=cert.pem). The certificate must name 127.0.0.1.
Start a simulator that accepts 5 MiB parts:
FILES_MOCK_MODE=simulation FILES_MOCK_MAX_BODY_BYTES=8388608 bundle exec pumaWith the Go SDK:
config := files.Config{APIKey: "placeholder", EndpointOverride: "http://127.0.0.1:4041"}.Init()
client := &file.Client{Config: config}
if err := client.Upload(file.UploadWithFile("report.pdf"), file.UploadWithDestinationPath("reports/report.pdf")); err != nil {
log.Fatal(err)
}
if _, err := client.DownloadToFile(files.FileDownloadParams{Path: "reports/report.pdf"}, "report-copy.pdf"); err != nil {
log.Fatal(err)
}With the Python SDK:
import files_sdk
files_sdk.base_url = "http://127.0.0.1:4041"
files_sdk.set_api_key("placeholder")
files_sdk.file.upload_file("report.pdf", "reports/report.pdf")
files_sdk.file.download_file("reports/report.pdf", "report-copy.pdf")The same steps with curl and jq:
API=http://127.0.0.1:4041/api/rest/v1
PART=$(curl -s -X POST $API/file_actions/begin_upload/hello.txt -H 'Content-Type: application/json' -d '{}')
REF=$(jq -r '.[0].ref' <<<"$PART")
ETAG=$(curl -s -D - -o /dev/null -X PUT --data-binary 'hello, world' "$(jq -r '.[0].upload_uri' <<<"$PART")" |
awk 'tolower($1) == "etag:" { gsub(/[\r"]/, "", $2); print $2 }')
curl -s -X POST $API/files/hello.txt -H 'Content-Type: application/json' \
-d "{\"action\": \"end\", \"ref\": \"$REF\", \"etags\": [{\"etag\": \"$ETAG\", \"part\": 1}]}" | jq -c .
curl -s -r 7-11 "$(curl -s $API/files/hello.txt | jq -r .download_uri)"; echo # world- Paths are stored exactly as sent, but the Files.com API compares them
with its comparison map, so
café.binandCAFE.binare one file there. Rather than store an entry the API would not, the simulator returns501for a new path that compares equal to an existing file or folder spelled differently, including as a parent folder. The same spelling still replaces its own file. Beginning an upload where a folder is, and a path two or more levels inside a file, also get501. - The API rejects a path with a name that, compared that way, is empty,
.or.., or contains a slash, such as..(two fullwidth dots) ora℀b. The simulator returns501for these instead of the API's error, and also for paths the API would rewrite: with a leading, trailing or repeated slash, a.or..segment, or a backslash. The API's request-path rules (see Files) and its destination rule (see Copying and moving) are checked; its other path rules, such as length limits, are not, so a path accepted here may still be refused by the API. Styles and the lists scoped by path (see Lists scoped by path or owner) do not apply the request-path rules, since the Files.com source read for this simulator does not establish that their actions do. Apathparameter that disagrees with the path in the URL gets422. - When a request breaks several parameter rules at once, the API reports all of them in one message. The simulator joins the messages for several invalid values, but reports only the first parameter left out, and none left out beside an invalid value.
- The upload profile is advertised, not enforced: parts sent at the
same time or out of order are accepted.
sizeis optional, as it is in the SDKs' own known-size uploads, and is checked only when it is sent. The simulator does not model or certify uploads of unknown size, adaptive part sizes or parallel parts, even where it accepts such requests. - The simulator checks that every uploaded part is listed, that a sent
sizematches the parts, and, withoutetags, that the received parts are numbered 1 to N. These are the simulator's own safeguards: the Files.com API may accept such requests, so a rejection here does not prove the API would reject them. Finalizing withoutetagsis simulated only for a sequential upload of known size on Files.com's own storage; remote server mounts and their providers, whose part rules differ, are not simulated. with_direct_connection_infois accepted, and no direct connection information is returned, which the API also allows.- These requests get
501:begin_uploadwithrestart,with_renameorbuffered_upload, or with arefbut nopart;POST /api/rest/v1/files/{path}with anactionother thanend, or withcustom_metadata,length,part,parts,restart,copy_behaviors,structure,with_renameorbuffered_upload;GET /api/rest/v1/files/{path}orGET /api/rest/v1/file_actions/metadata/{path}withpreview_size,with_previewsorwith_priority_color, or with anactionother thanstat;GET /api/rest/v1/folders/{path}with any parameter other thancursorandper_page(such assort_by,searchortype), or for a file's path; and a recursive delete of the root. - Files and folders do not report who created them, permissions or
locks, and checksums such as
md5are not returned. Listings do not returnX-Files-Cursor-Prev.
Locks are advisory, as in the Files.com API: a lock never stops a file being written, moved or deleted, and it stays at the path it was made for when the file moves or goes away. Clients check locks themselves. The simulator implements the public list, create and delete operations from the Files.com lock controller and model as read in their source, not as observed on a live site.
Creating. POST /api/rest/v1/locks/{path} creates a lock only when
allow_access_by_any_user and exclusive are both true (JSON
booleans, or the strings "true" and "false", as a form body sends
them). Any other combination, including a missing or null flag, gets
422 bad-request/request-params-required with the message
Required request parameter missing: token, allow_access_by_any_user, or exclusive:
the API accepts other combinations only with a token, type or scope,
which are internal parameters the public schema does not declare. A
value of the wrong type is refused first, with 422 bad-request
(exclusive is invalid), and the flags are checked before the path and
any conflict.
A lock applies to its subfolders unless recursive is false, and
lasts timeout seconds: 43200 (12 hours) when it is missing, zero or
negative, and at most 604800 (one week). The answer is 201 and the
Lock:
{ "path": "docs/report.txt", "timeout": 60, "depth": "infinity", "recursive": true, "scope": "exclusive", "exclusive": true,
"token": "0c4c8f5e-6b0a-4f3e-9a55-3d1f2b8e7c11", "type": "office", "allow_access_by_any_user": true, "user_id": -1 }depth is "0" for a lock that does not apply to subfolders. The token
is a new UUID. user_id is -1 because the simulator authenticates
nobody (as with a site-wide API key), so username is left out, and so
is owner, which only an internal parameter sets. The path is kept
exactly as sent and must be in the form the API keeps paths in (see
Differences from the Files.com API).
It follows the API's path rules for a file's path (see Files):
the lock's own name may end in whitespace, the folders above it may not,
and a zero-width space is refused. It need not name a file or folder.
How locks are found. The simulator finds locks the way the model
does: it keeps each lock as PATH:ID in a sorted index and reads the
index by byte prefix, not by folder. The locks at a path are those whose
entry starts with PATH:, so a lock at a:b is also at a. The range
from a path holds every entry that starts with the path: the path's own
locks, the locks inside it at any depth, and those at names that only
start with it, such as a2 and a2/x for a. Each folder above a path
is looked up at its own name, so a2 is never above a/x. Entries come
in index order: by PATH: byte by byte (a/b before a, since /
sorts before :), and in creation order within a path.
Conflicts. Every lock the public API can create is exclusive, so a new one conflicts with every lock the model's validation compares it with. In order, these are:
- its siblings, the live locks at its path;
- every lock at each folder above it that applies to subfolders, nearest folder first;
- when the new lock applies to its own subfolders, everything in its range: the locks inside it and at names that only start with it, and its own path's locks a second time.
The answer is 422 processing-failure/resource-locked, whose message
lists them as the model does:
exclusive lock 1f0e… at /docs, exclusive lock 9a2c… at /docs/a.txt, exclusive lock 1f0e… at /docs.
So creating a2 and then a lock at a that applies to subfolders is
refused, while one at a that does not apply to subfolders is created. A
lock above that does not apply to subfolders never conflicts.
Listing. GET /api/rest/v1/locks/{path} answers the live locks at the
path and those at each folder above it that apply to their subfolders,
sorted by path. With include_children=true it answers the path's range
instead of its own locks, no more than one level deeper than the path.
For a, that means a, a/b, a2 and a2/x, but not a/b/c. The
action answers this whole array: cursor (a string) and per_page (a
whole number, any value) are checked as declared and change nothing, and
no cursor comes back.
Releasing. DELETE /api/rest/v1/locks/{path}?token=… answers 204
for the lock with that token at the path. When several match, the one
with the latest deadline is released, provided it has not expired. Any
other token or path, an expired lock, a lock released already and a lock
from before a reset get 404 not-found. A token left out gets 422
bad-request (token is missing), as the API's parameter validation
answers it; one sent null or empty passes that validation and, after the
path rules, finds no lock (404). Every lock
the public API can create allows any user of the site to release it, and
the simulator is one synthetic site: a lock made with one API key is
listed and released with any other.
Time. The lock clock is whole seconds, 0 at every reset, and moves
only when a test advances it (POST /__files_mock/v1/locks/clock), so
expiry never depends on how fast tests run. A lock expires once its
deadline (its creation time plus its timeout) is before the clock; at a
deadline equal to the clock it is still live. An expired lock is not
listed, not releasable and not a sibling of a new lock, but it is still
held. The model's queries for the folders above a path and for a range
keep expired locks, so an expired lock goes on conflicting in two cases:
- as a lock above a new lock, when it applies to subfolders;
- as anything in a recursive new lock's range, including a lock at that very path.
A new lock at the same path that does not apply to subfolders is created.
This lasts until POST /__files_mock/v1/locks/cleanup removes every
expired lock, as the model's removal job does when it runs. Nothing else
removes an expired lock before the next reset.
Held locks, expired ones included until a cleanup, count against
FILES_MOCK_MAX_RECORDS; past it a create gets 409
simulation/limit-exceeded. token, type, scope, depth, owner
and bundle_registration_code, which the API takes but the public schema
does not declare, get 501: they change what the request does (a token
refreshes an existing lock), so they are refused rather than ignored.
API=http://127.0.0.1:4041/api/rest/v1
TOKEN=$(curl -s -X POST $API/locks/docs/report.txt -H 'Content-Type: application/json' \
-d '{"allow_access_by_any_user": true, "exclusive": true, "timeout": 60}' | jq -r .token)
curl -s "$API/locks/docs?include_children=true" | jq -c 'map(.path)' # ["docs/report.txt"]
curl -s -X POST http://127.0.0.1:4041/__files_mock/v1/locks/clock -H 'Content-Type: application/json' -d '{"advance_seconds": 61}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$API/locks/docs/report.txt?token=$TOKEN" # 404: expiredThe contract comes from the Files.com source (the lock controller and
FileLock model), not from a live site. These points stay open or differ:
- Who may act on another user's lock (site administrators, the lock's
user, a parent site's users) and write permission are not simulated.
Without write permission the API answers a list with
[]; the simulator grants every request. - The model's lock IDs are not simulated, so the order of several locks at one path, in a conflict message or a list, is the simulator's creation order.
- The model's removal job selects locks by the second of their deadline, so it can remove a lock during its last second; the simulator's cleanup removes only expired locks. How often the job runs is not known and is not simulated.
- Refreshing a lock needs its internal token, so it is not simulated. The source also computes a refreshed deadline from the timeout as given, not as limited to one week or defaulted, so a refresh and a create treat out-of-range timeouts differently.
- Whether the API's model validation (
422processing-failure/model-save-error) can be reached with public parameters is not established; the simulator never answers it. - Two exclusive creates at the same moment cannot both succeed here, since every request is applied under one lock; whether the API's creates are atomic is not established. Paths are compared exactly, including case, as the model's index stores them.
- The source reads the API's own list presentation, not its middleware; that no layer outside the action pages this list is not established.
Tests control the simulator directly through endpoints under
/__files_mock/v1. These requests are never counted as API traffic.
POST requests must send Content-Type: application/json.
Poll this until it answers 200, with a time limit, before running
tests. It identifies the server and what it simulates:
{
"status": "ready",
"mode": "simulation",
"contract_version": 3,
"simulator_version": "1.0",
"schema_sha256": "8931ce67…",
"instance": "f4e868240921",
"epoch": 0,
"operations": [
{ "id": "users.create", "method": "POST", "path": "/api/rest/v1/users", "swagger_operation_id": "PostUsers" },
{ "id": "groups.create", "method": "POST", "path": "/api/rest/v1/groups", "swagger_operation_id": "PostGroups" }
],
"records": { "resources": 104, "singletons": 1, "unset_fields": "omitted", "write_only": "password|passphrase|secret|…", "filtering_and_sorting": false, "uniqueness": false, "foreign_keys": false },
"transfers": {
"operations": [ { "id": "transfers.upload_part", "method": "PUT" }, { "id": "transfers.download", "method": "GET" } ],
"origin": null,
"upload_parts": { "http_method": "PUT", "parallel_parts": false, "retry_parts": true, "partsize": 1048576 },
"max_uploads": 64, "max_parts": 64, "finalize_etags": [ "listed", "omitted" ],
"file_actions": { "operations": [ "files.copy", "files.move" ], "overwrite": "files replace files and folders merge into folders", "structure": true, "copy_behaviors": false, "migration_statuses": [ "pending", "processing", "completed", "failed" ] },
"zip": { "operations": [ "files.zip_list", "files.unzip", "files.zip" ], "zip_creation": true, "methods": [ "stored", "deflate" ], "entry_names": [ "utf-8-flagged", "ascii" ], "zip64": false, "crc_checked": false, "unzip_migration": "pending, applied when polled; the whole extraction is checked before any change", "zip_migration": "pending, made when polled from the files at that moment; the destination is checked then" },
"download_request_status": { "operation": { "id": "transfers.download_status", "method": "GET", "path": "/__files_mock/transfer/download/{version}/{request_id}" }, "headers": [ "X-Files-Download-Request-Id", "X-Files-Download-Id" ], "selected": null, "size": "sent", "issued": 0, "held": 0, "transfers_held": 0, "max_held": 1000 },
"state": { "uploads": 0, "files": 0, "bytes_in_use": 0 }
},
"namespace": {
"site_policy": { "always_mkdir_parents": true },
"listing": { "order": "path", "default_per_page": 1000, "max_per_page": 10000, "max_cursors": 1000, "next_cursor_headers": [ "X-Files-Cursor", "X-Files-Cursor-Next" ] },
"delete": { "recursive": true, "root": false },
"state": { "files": 0, "folders": 0, "cursors": 0 }
},
"locks": { "clock_seconds": 0, "held": 0, "expired": 0, "max_held": 1000, "default_timeout": 43200, "max_timeout": 604800, "creatable": { "allow_access_by_any_user": true, "exclusive": true } },
"real_only": [ "authentication", "api key and site identity", "workspaces", "permissions", "lock agency", "behaviors", "creator ids" ],
"fixtures": [ "users", "action_logs", "…", "site", "folders", "files" ],
"profile": { "file_actions": "completed", "upload": { "mode": "legacy", "retry_parts": true, "partsize": 1048576, "part_offset_query": false }, "download": { "identity": "absent" }, "list_cursors": "opaque", "site_policy": { "always_mkdir_parents": true } },
"profile_choices": { "file_actions": [ "completed", "pending" ], "list_cursors": [ "opaque", "special-characters" ], "upload": { "mode": [ "legacy", "serial", "parallel" ], "upload_target_class": [ "s3", "agent_fiw", "generic" ] }, "download": { "identity": [ "absent", "contract_v1" ], "request_status": { "status": [ "completed", "started", "failed", "error" ], "header_on_failure": [ false, true ] }, "size": [ "sent", "withheld" ] }, "site_policy": { "always_mkdir_parents": [ true, false ] } },
"faults": {
"match": { "users.list": "continuation", "users.update": "id", "files.copy": "path", "transfers.upload_part": [ "path", "part" ] },
"statuses": [ 400, 401, 403, 404, 408, 409, 412, 422, 423, 429, 500, 502, 503, 504 ],
"retryable_statuses": [ 408, 429, 500, 502, 503, 504 ], "permanent_statuses": [ 400, 401, 403, 404, 409, 412, 422, 423 ],
"kinds": [ "error", "unstructured", "redirect", "expired_url", "html_page", "empty_page", "delay", "hold", "drop_before", "drop_after", "truncate", "short_body", "excess_body", "stall" ],
"connection_kinds": [ "drop_before", "drop_after", "truncate", "short_body", "excess_body", "stall" ],
"random": { "session_key": "session", "max_keys": 10000, "max_schedule": 1000 },
"max_attempt": 100, "max_retry_after": 60, "max_delay_ms": 10000, "max_hold_ms": 120000, "max_holds": 2, "max_rules": 100
},
"pagination": { "order": "id", "default_per_page": 1000, "max_per_page": 10000, "next_cursor_headers": [ "X-Files-Cursor", "X-Files-Cursor-Next" ] },
"limits": { "max_records": 1000, "max_journal_entries": 10000, "max_body_bytes": 1048576, "max_transfer_bytes": 33554432 },
"state": { "users": 0, "records": 0, "singletons": 0, "file_migrations": 0, "journal_entries": 0, "journal_complete": true, "pending_faults": 0 }
}(Shortened.) contract_version changes when this control or simulation
contract changes incompatibly. schema_sha256 identifies the API schema
subset the simulator validates against (lib/simulation/schema.json, which
the generator renders from the Swagger document; beside it,
lib/simulation/generation.json records that document's name and SHA-256,
the schema subset's SHA-256, the reported version, the generator checkout's
commit and whether its generator and mock sources were clean, and the
command; these are the generator's own statements). instance identifies
the server process: a new random value (12 lowercase hex characters) for
every process, unless FILES_MOCK_INSTANCE is set when it starts, which
lets whoever starts the server recognize it here (as with-scenario.rb
does with a new random value for each run). FILES_MOCK_INSTANCE must be
12 lowercase hex characters; any other value stops startup with an error,
and an empty value counts as unset. The instance is not a credential: any
process of the same user can read it from the server's environment, and
the simulator still accepts any credential. epoch counts resets.
records summarizes the
record resources and what they do not check. transfers lists the byte
transfers to issued URLs, the current upload advertisement and transfer
limits, the FILES_MOCK_TRANSFER_ORIGIN setting (null when URLs use the
address each request arrived on), how an upload may be finalized
(finalize_etags), how copies and moves behave, the download request
status route and headers with the reset's selection (null when none),
size setting, the IDs issued since the reset, the requests and issued
download URLs held, and how many of each are allowed (see Download
request status), and the unfinished uploads, files and
file bytes held now. namespace gives the simulated
site policy for parent folders, how folders are listed and deleted, and
the files, folders and listing cursors held now. locks gives the lock
clock, the locks held (expired ones included until a cleanup) and the
timeouts a lock may have, or is null when the Swagger document declares
no locks (see Locks). profile is the current
reset's profile and profile_choices the values it may take. real_only
names what only a real Files.com site can answer: the simulator accepts
any credential and reports no identity, permissions, lock agency (who
may act on another user's lock), behaviors or creators, so their absence
here is never evidence about a real site.
contract_version 2 added folders, deletes and finalizing without
etags; 3 added record resources for every regular resource, the site
singleton, copies, moves, recursive deletes and file migrations, file
and folder fixtures, profiles, fault kinds and random faults, and the
inventory.
Replaces all state at once: records and their ID counters, uploads,
files, folders, listing cursors, file migrations, locks and the lock
clock, fault rules, the journal and the profile. fixtures may fill any record resource (an
array of records, checked like its create, or like its fields when it
has no create), the site (one object), folders (an array of paths) and
files (an array of {"path", "text" or "base64", "provided_mtime"},
loaded last). Records get IDs from 1 in the order given, so sending the
same fixtures always produces the same records and IDs. profile sets
the transfer profiles and file_actions; leaving
it out restores the defaults.
curl -X POST http://127.0.0.1:4041/__files_mock/v1/reset \
-H 'Content-Type: application/json' \
-d '{"fixtures": {"users": [{"username": "alice"}, {"username": "bob", "ssl_required": "always_require"}]}}'{ "epoch": 1, "users": [ 1, 2 ] }A fuller example, with a group, a folder, a file and a serial upload profile:
curl -X POST http://127.0.0.1:4041/__files_mock/v1/reset -H 'Content-Type: application/json' -d '{
"profile": { "upload": { "mode": "serial", "partsize": 5242880, "retry_parts": true } },
"fixtures": { "groups": [ { "name": "admins" } ], "folders": [ "inbox" ], "files": [ { "path": "inbox/hello.txt", "text": "hello" } ] }
}'Send {} to reset to an empty simulator. If any fixture is invalid or
over a limit, the reset is refused and the previous state is kept. After
a reset, cursors, upload refs, and upload and download URLs issued before
it are rejected, and a request the simulator was already handling when
the reset happened, including a part body it was still receiving, is
refused with 409 (simulation/stale-request) instead of being applied
to the new state. A download already being sent finishes. Reset when
your test's own requests have finished.
An explicit fault rule makes one future request fail, and it is used
exactly once. A random rule decides every matching request from its seed
(see below). A request is matched after its body has been read and
parsed, and before its parameters are validated or anything changes. A
request refused while being read (a body that is too large, not JSON, or
not valid JSON) never counts toward a rule. A request with invalid
parameters does count, and gets the fault instead of the 422.
| Field | Required | Meaning |
|---|---|---|
operation |
yes | A simulated operation, for example users.update or groups.find. |
kind |
no | What the rule does (default error); see the table below. |
status |
for error, unstructured |
One of faults.statuses in readiness. For redirect, 301, 302, 303, 307 (default) or 308. |
type, message |
no | For error: a Files.com error type such as not-found or bad-request/invalid-cursor, and its message, instead of simulation/injected-fault. |
data |
no | For error: a JSON object of at most 4096 bytes sent as the error's data, such as {"host": "127.0.0.1:4043"} with the type not-authenticated/lockout-region-mismatch, so a client's region reroute can be checked against a local answer. |
match |
no | Limit the rule to one record: {"id": 2} for users.find, users.update and users.delete, or {"username": "alice"} for users.create. List rules may match {"continuation": true} (a request that sends a cursor) or false, and folder listings also path; record operations on /{name}/{id} match {"id": N}, except those of a record whose id is a string, which take no match. File operations and downloads match on the file's path, and transfers.upload_part on path, part or both, as in {"path": "reports/report.pdf", "part": 2}. |
attempt |
no | Fail the Nth matching request after the rule is added (1 to 100, default 1). |
retry_after |
no | Seconds to send in a Retry-After header (0 to 60), for error and unstructured. |
delay_ms, when |
for delay, stall, hold |
Milliseconds (1 to 10000; for hold, 1 to 120000); for delay, before (default) or after the request is applied. |
bytes |
for body kinds | Where a body is cut, how much is left off or added, or where it pauses. |
location |
for redirect |
The origin to redirect to, such as http://127.0.0.1:4042. |
cursor |
for empty_page |
advance (default) or repeat. |
random |
no | {"seed", "rate", "key", "max_faults"}; makes the rule random (then attempt does not apply). |
| Kind | What happens |
|---|---|
error |
A Files.com API error body with the status, and an x-files-mock-fault header naming the rule. Nothing is applied. |
unstructured |
An HTML error page with the status, as a proxy or load balancer answers. Nothing is applied. |
redirect |
The status with Location set to the rule's origin followed by the request's path and query. Another simulator on that origin journals which identity headers arrived. |
expired_url |
For transfers: 403 with the XML Request has expired, as an expired presigned URL answers. |
html_page |
For downloads: 200 with a web page and X-Files-Frontend-App: true instead of the file's bytes. |
empty_page |
For lists: 200 with no items and a cursor that continues from the same place (advance) or the cursor the request sent (repeat). |
delay |
Holds the request before it is applied (other requests go on) or holds its response after. |
hold |
Holds the request before it is applied, for up to 120 seconds: long enough to pass a client's default read timeout (the SDKs' is 80 seconds). It ends early, and the request is then applied at once, when the client closes its connection or sends anything more, as a client that gave up does; a reset ends it too, and the request is then refused as stale. Only explicit rules hold (random does not apply), and at most two hold rules may be pending or holding a request at once: a rule keeps its place from when it is added until the request it selected has been applied or refused (a reset, a closed connection or an error included), so held requests never occupy every server thread. GET /__files_mock/v1/faults gives the requests being held now as holding, a reset's replaced state included until they end. The journal records hold_ms, held_ms and hold_ended (elapsed, client-closed or reset). |
drop_before |
Closes the connection without a response; nothing is applied. |
drop_after |
Applies the request, then closes the connection without a response. A client cannot tell this from drop_before; only the state shows it. |
truncate |
Sends the declared Content-Length but only bytes bytes of the body, then closes. |
short_body |
Sends no Content-Length and leaves the last bytes bytes off, then closes. |
excess_body |
Sends no Content-Length and adds bytes extra bytes, then closes. |
stall |
Sends the headers and bytes bytes, pauses delay_ms, then sends the rest. |
Connection kinds (drop_before through stall) write to the connection
themselves, so they need a server that supports Rack hijacking, such as
bundle exec puma; elsewhere the request gets 501 and the rule stays
pending. They work over TLS binds too. The journal records how a
response was delivered (fault_kind, sent_bytes, body_bytes,
paused_at, delay_ms).
A random rule decides each matching request from its seed, its rate
(greater than 0, at most 1), the request's values for the rule's key
(any of the operation's match keys, and session, the credential the
request carries) and how many earlier matching requests had those
values. Requests with other key values never change a decision, so the
same requests per key get the same decisions in any interleaving, and
adding the same rule after another reset or to another simulator
replays which requests it selects. What happens to a selected request
does depend on what else arrived: explicit rules come first (the random
rule records overridden), a connection fault the server cannot make is
unavailable, among random rules the first one added that selects a
request fails it, and max_faults is a budget for the whole rule, so
once it has failed that many requests it records later selections as
budget-exhausted. Which selected requests the budget goes to therefore
follows arrival order. GET /__files_mock/v1/faults lists every decision
(selected, and the outcome fault, pass, overridden, unavailable
or budget-exhausted) with the key values and attempt number, and counts
each outcome; sessions appear only by number. List operations match continuation (true for a request
that sends a cursor), so a rule can fail only later pages.
curl -X POST http://127.0.0.1:4041/__files_mock/v1/faults -H 'Content-Type: application/json' \
-d '{"operation": "transfers.upload_part", "status": 503, "retry_after": 0, "random": {"seed": "run-42", "rate": 0.25, "key": ["path", "part"]}}'For example, to make the first update of user 2 fail once:
curl -X POST http://127.0.0.1:4041/__files_mock/v1/faults \
-H 'Content-Type: application/json' \
-d '{"operation": "users.update", "match": {"id": 2}, "status": 503, "retry_after": 1}'The next PATCH /api/rest/v1/users/2 returns:
{ "error": "Simulated 503 response from fault rule 1", "http-code": 503, "title": "Service Unavailable", "type": "simulation/injected-fault" }Requests for other operations or other records never use the rule, even
when they arrive at the same time. A new rule that could match the same
requests as a rule that is still pending is refused with 409.
A fault stores and publishes nothing, so a test can check an SDK's own retry. To make the first attempt at part 2 of an upload fail once:
curl -X POST http://127.0.0.1:4041/__files_mock/v1/faults \
-H 'Content-Type: application/json' \
-d '{"operation": "transfers.upload_part", "match": {"path": "reports/report.pdf", "part": 2}, "status": 503, "retry_after": 0}'GET /__files_mock/v1/faults lists every rule since the last reset as
pending or consumed, with matched_requests and the journal
consumed_by_request sequence number. Check that the faults your test
added were consumed; a pending rule means the failure never happened.
Lists the API requests since the last reset, oldest first:
{
"epoch": 1,
"entries": [
{ "seq": 2, "epoch": 1, "method": "PATCH", "path": "/api/rest/v1/users/2", "operation": "users.update", "id": 2, "fault_id": 1, "status": 503 }
],
"limit": 10000,
"dropped": 0,
"complete": true,
"upload_parts": { "in_flight": 0, "most_in_flight": 0 }
}upload_parts counts places, not entries: in_flight is the upload
parts holding a place now, and most_in_flight the most that held one
at once since the last reset. The simulator counts a place under its
lock at the moment it admits a part, before the part's body is read,
across every upload not yet finalized (the count
upload.max_concurrent_parts limits). It gives the place back once it
has applied or refused the part and made its response, or as soon as the
part's body cannot be read. That is not an acknowledgement that the
response reached the client. A reset starts both at 0; a part admitted
before a reset is not counted in the state that replaced it, and a part
of an upload that is finalized while it is being sent no longer counts.
Entries record the operation (or null for a request that is not
simulated), the record ID, the response status and any fault rule used,
the JSON type of each request value (wire, such as "string" or
"number"), for each array value its element count and the distinct
JSON types its elements arrived as, before any conversion
(wire_elements, such as {"amounts": {"count": 3, "types": ["string"]}}; a query value is always a string), which identity headers the request carried (credentials:
the API key or session by its number in order of first appearance,
never its value, and the workspace ID as sent), its user_agent, and
arrived_ms and applied_ms, milliseconds since the reset on a
monotonic clock, from which request timing and a client's concurrency
can be read. A request arrives before it waits for the simulator's lock
and, for a part, for a place, so these spans do not show how many parts
held a place at once; upload_parts does.
File and transfer entries also name the upload, part and file
version they used, and bytes and sha256 for the bytes a part
stored or a finalize published. A download entry has the length of the
response and the version's SHA-256; it is written when the response
starts, so it does not show how many bytes the client received. Compare
downloaded bytes yourself. Under a download.request_status profile a
download entry also has the number of the ID its response carried
(download_request), and a transfers.download_status entry the
version its URL named, the download_request found by its ID, and how
the lookup went (download_request_lookup: found,
found_for_other_url, no_transfer for a request found at a download
URL not held, evicted, earlier_reset or not_issued). Stat, metadata, delete and folder-create
entries give the type found or deleted (file or directory). A
folder listing entry has its number of items, and the SHA-256 of the
cursor it received (cursor_sha256) and of the one it returned
(next_cursor_sha256), so a test can check that each page was requested
with exactly the cursor the page before it returned.
A users.list entry also has paging, which describes each paging
value by whether it was there (present, by key, whatever the value),
its JSON type (wire_type, as in wire, or null when absent) and
the SHA-256 of its bytes when it is a string (sha256, the empty string
included, else null), never by the value itself:
request:per_pageandcursoras the request sent them, read from its parsed query and body before anempty_pagecursor is resolved or any value is checked.per_page.integeris the value read as a 32-bit whole number, ornullwhen it is missing or is not one (the list still answers422for it), andcursor.nonemptyis true only for a non-empty string.availableisfalsewhen the request's parameters could not be read.response: theX-Files-Cursor(cursor) andX-Files-Cursor-Next(cursor_next) headers of the response the simulator selected, each read on its own, after any fault rule chose an error, a redirect or an empty page instead of the page.availableisfalsewhen no response was selected (drop_before).basisis alwaysselected-rack-response-before-delivery: this is the response the simulator chose to send, not what a client received, since a connection fault may still delay, cut short or drop it.
A missing, unavailable or malformed value proves nothing, and two
missing values are not equal. A traversal continued each page when each
request's cursor.sha256 equals the previous response's
cursor_next.sha256, both non-empty strings, and it ended when an
available 200 response has neither header.
Entries never contain request bodies, file content, cursors, query
strings, headers or credentials. When the journal is full, later requests still run, but
they are counted in dropped and complete becomes false; treat an
incomplete journal as missing evidence.
POST /__files_mock/v1/locks/clock with {"advance_seconds": N}, a whole
number from 1 to 2147483647, moves the lock clock forward and answers
{"epoch", "clock_seconds"}. POST /__files_mock/v1/locks/cleanup with
{} removes every expired lock and answers {"epoch", "removed", "held"}.
Any other field in either body gets 400. A
server whose Swagger document declares no locks answers both with 404
simulation/unknown-control. See Locks.
| Status | Type | Meaning |
|---|---|---|
| 501 | simulation/not-supported |
The operation, parameter or request body type is not simulated. |
| 409, 413 | simulation/limit-exceeded |
A limit below was reached. Nothing was changed. (Puma's own 413 for an oversized body is plain text; see Limits.) |
| 409 | simulation/stale-request |
The simulator was reset while it was handling the request. |
| 409, 422, 503 | simulation/profile-violation |
A request broke a rule the reset's transfer profile advertises. |
| any | simulation/injected-fault or the rule's type |
A fault rule you added (with an x-files-mock-fault header). |
| 403 | SignatureDoesNotMatch, AccessDenied (XML) |
A presigned URL sent with a changed or missing query. |
| 404 | download_request_not_found (with http_code) |
A download request status lookup for a request or a download URL the simulator does not hold; see Download request status. |
| 404 | simulation/no-inventory |
The server was generated without an inventory. |
| 400, 409, 415 | simulation/invalid-control-request |
A control request was malformed or conflicts with a pending fault rule. |
| 404 | simulation/unknown-control |
There is no such control endpoint. |
Errors use the Files.com API error shape, with error, http-code,
title and type fields.
Simulation mode rejects work over these limits instead of truncating it. Set them with environment variables at startup:
| Variable | Default | Maximum | Limits |
|---|---|---|---|
FILES_MOCK_MAX_RECORDS |
1000 | 100000 | Users held at once, and separately files and folders held at once, and locks held at once (expired ones included until a cleanup). |
FILES_MOCK_MAX_JOURNAL_ENTRIES |
10000 | 100000 | Journal entries kept between resets. |
FILES_MOCK_MAX_BODY_BYTES |
1048576 | 16777216 | Size of a request body, including each upload part. |
FILES_MOCK_MAX_TRANSFER_BYTES |
33554432 | 1073741824 | File content held in memory. |
At most 100 fault rules can be added between resets, 64 uploads can be
unfinished at once, an upload can have up to 64 parts, 1000 folder
listing cursors can be issued between resets, 100 distinct credentials
can be numbered between resets (a request with a new one after that gets
409 before anything changes; a refused upload part send counts nothing
for the part), pending copies and moves keep at most
FILES_MOCK_MAX_RECORDS file migrations until the next reset, and a
reset keeps the newest FILES_MOCK_MAX_RECORDS download requests and issued download URLs. These limits
protect the machine running your tests; they are not Files.com API
limits.
The default 1 MiB body limit gets 413 for any larger body, so a
client that sends larger parts (the Go SDK sends up to 5 MiB per part,
whatever partsize says) cannot finish a bigger upload under it. The
transfer profile is the fixed setting for transfers that should
succeed: FILES_MOCK_MAX_BODY_BYTES=8388608 (8 MiB), under which the
advertised partsize is 5 MiB and a 5 MiB + 257 byte upload fits in two
parts without client overrides, within the default
FILES_MOCK_MAX_TRANSFER_BYTES. Readiness limits shows which limits a
server runs with.
FILES_MOCK_MAX_TRANSFER_BYTES counts all the file content the
simulator holds: uploaded parts, files, a part body from the moment the
simulator starts reading it, and a replaced file's bytes until a
download that started before the replacement finishes. A part body that
would pass the limit is refused with 409 before it is read. A reset
releases everything except bytes a download in progress is still
sending; the transfers.state.bytes_in_use readiness field shows the
current count.
A chunked upload part has no declared length, so it reserves
FILES_MOCK_MAX_BODY_BYTES until its actual size is known. Near the
transfer limit, even a small chunked part can therefore get 409.
Beyond that content, the server holds at most one request body for each
request Puma is serving. With the bundled config/puma.rb, Puma reads
each body on the thread that serves its request (up to 5 threads unless
-t sets more), and connections beyond that wait unread. A body of up
to 112 KiB is kept in memory, and a larger one in an unlinked temporary
file until the simulator reads it. JSON and form request bodies are then
parsed in memory (a JSON number's text stays within its digits plus 32
zeros, so numeric text stays within about seven times the body), and downloads are sent in 64 KiB slices of the stored bytes.
The path comparison map, loaded once at startup, adds a fixed amount.
Ruby's own memory, including garbage it has not yet collected, comes on
top of these, so measure your own setup if memory is tight. A client
that sends its body slowly keeps its thread busy until it finishes.
A request body over FILES_MOCK_MAX_BODY_BYTES gets 413 and changes
nothing. With the bundled config/puma.rb, Puma refuses it before the
simulator sees the request: a declared Content-Length over the limit
before any of the body is read, and a chunked body as soon as the
received chunks cross the limit. That 413 is Puma's plain-text
Payload Too Large response, not the JSON error shape, Puma closes the
connection, and the request does not appear in the journal. The
simulator checks the same limit itself, so under another server or
Puma configuration an oversized body still gets a journaled 413
(simulation/limit-exceeded).
Each simulation server keeps its state in its own process memory and
shares nothing with other servers, even when they are given the same
fixtures. Start one server per test run, on its own port or in its own
container, and reset it between scenarios. Simulation mode refuses to
start Puma with workers (-w or WEB_CONCURRENCY), because each worker
would hold a separate copy of the state.
The simulator has no authentication of its own. It listens on loopback by default, does not send CORS headers, and should only be reachable from the tests that own it.
The SDKs and the CLI test common API behavior against this one server.
with-scenario.rb runs one test command against a server of its own,
loaded with a scenario from scenarios/; each language still makes its
own SDK or CLI calls and asserts its own results:
ruby with-scenario.rb --scenario folder-list --out "$OUT" -- go test ./cmd -run TestFolders_ListFor_WithPreviewsRun it with plain ruby, from any directory; the server always uses
this directory's Gemfile, even when the caller runs under another
project's bundle exec. The launcher:
- starts
FILES_MOCK_MODE=simulation bundle exec pumaon a free port of127.0.0.1(never another interface), with a new randomFILES_MOCK_INSTANCEfor this run that it gives to no one else until the server is ready; - waits for readiness (
--startup-timeout, 60 seconds by default) and requires simulation mode, contract version 3, the expectedschema_sha256(--schema-sha256, elseFILES_MOCK_SCHEMA_SHA256, elselib/simulation/generation.json) and this run's instance, from a server process that is still running, checked before each attempt and again after the answer; - resets the server with the scenario's
resetbody; - runs the command, in a process group of its own, without a shell and
with no standard input (
--consumer-timeout, 900 seconds by default), with the variables below; - then, however the run ended (the command passed, failed or timed out,
an error, or
INT,TERMorHUP), cleans up in this order: it stops whatever is left of the command's process group, its descendants included; saves the journal and runs the scenario's journal checks, if the server was ready; stops the server's process group; and writesrun.json.
Each process group is stopped with TERM, then, after --settle-timeout
(15 seconds by default), KILL and the same wait again; only the
launcher's two groups are signalled, and a process that left its group
(with setsid, for example) is not stopped. A group counts as stopped
only when the system reports it has no process left; a group it cannot
show to be empty, for example because a check is refused, is recorded as
unsettled. Saving the journal is best effort and bounded (2 seconds to
connect, 30 to answer): a journal that cannot be read, is incomplete or
dropped entries is recorded as missing evidence and never replaces the
run's own outcome. After INT, TERM or HUP, the launcher still ends
by that signal once it has cleaned up.
The free port is released before the server binds it, so another service
on 127.0.0.1 could bind it first. A 200 readiness answer that is not
a JSON object, or that reports another instance, mode, contract version
or schema, ends the run with 3 at once, as does the server exiting
before it is ready: the launcher does not keep polling another service,
and neither the reset, the command nor a journal request reaches it. The
instance only tells the launcher's own server apart; it is not
authentication, and it is not hidden from other processes of the same
user.
| Variable | Value |
|---|---|
FILES_MOCK_SERVER_URL |
http://127.0.0.1:PORT, the only address the test may call |
FILES_MOCK_SERVER_HOST, FILES_MOCK_SERVER_PORT |
The same address in parts |
FILES_MOCK_API_KEY |
A placeholder key for this run: filesmock followed by the letters and digits of RUN_ID, with its ., _ and - dropped. It holds only letters and digits, because SDKs such as Python accept nothing else. Never a real credential |
FILES_MOCK_RUN_ID |
--run-id, or a new UTC time and random suffix |
FILES_MOCK_INSTANCE, FILES_MOCK_SCHEMA_SHA256 |
The running server's instance (the one this run chose) and schema SHA-256 |
FILES_MOCK_SCENARIO, FILES_MOCK_SCENARIO_FILE |
The scenario's name and its JSON file, from which a test reads what to expect |
FILES_MOCK_OUT |
The --out directory |
--out must be a directory without an earlier journal.json. It
receives ready.json, reset.json, journal.json, server.log and
run.json. run.json records the run ID and placeholder key; the server
(url, instance, schema_sha256, contract_version, log,
process_group); the command (argv, status, timed_out,
process_group); the journal (complete, dropped, entries) and each
journal check's result; setup_error; interrupted (the signal, such as
SIGTERM, or the error that ended the run, else null);
missing_evidence; and unsettled_process_groups (server, consumer
or both). The exit status is the command's when it failed (124 when it
timed out, 127 when it could not be started); 3 when the server could
not start, was not ready, reported another schema or instance (such as
another service answering its port) or refused the scenario (the command
is then not run); 4 when the command passed but evidence is missing or a journal check failed; 5 when everything else passed but
a process group is unsettled; 2 for a usage error; and 0 otherwise.
A scenario is a JSON object with a name, a description, the reset
body, and what its tests need in order to check their own results.
journal_checks names checks the launcher runs on the journal; they see
only what the server received and never stand in for a test's own
assertions. Parallel runs each get their own server, port and run ID.
folder-list holds the folder shared-folder-list with five child
folders. Its children are in listing order and per_page is the page
size its tests should ask for; the children are folders, so a listing
with with_previews is the same. Its check, folders.list cursor chain, follows the listings of shared-folder-list in order (by the
journal's cursor SHA-256s): each traversal starts without a cursor and
continues with exactly the cursor its previous page returned, a new
traversal starts only after the one before it reached its last page, and
the last traversal must reach its last page. Several complete traversals
pass, so a test should finish every traversal it starts.
A shared scenario adds coverage; it does not replace a language's own
tests. The SDKs keep their existing tests, for example Python's malformed,
empty, repeated and cyclic cursor, list-context and early-stop tests and
the CLI's folder preview test. Once a target's CI job provisions this
server from the same schema, its own test entry points (Python's
tasks.py, the CLI's test.sh) run the shared scenario as a required
test, never one that is normally skipped.
GET /__files_mock/v1/inventory lists every operation the simulator does
not simulate, with its gap kind and reason: actions whose effects need an
owner's simulated contract (running automations and syncs, GPG file
actions, incident and partner pairing state), actions that deliver
or reach providers (share and welcome email, webhook and SIEM tests,
agent and SSO actions), and authentication actions (unlocking a user,
resetting two-factor authentication). Operations whose result
depends on who is authenticated (sessions, the current API key) are
real-only. Authentication, permissions, uniqueness, general filtering
and sorting and foreign keys are not simulated either. Requests for these
return 501 in simulation mode. Use legacy mode for tests that only need
example responses for them.
./test.sh checks the server's code with its own RuboCop (./lint.sh, which
also fails when RuboCop finds no files to check) and runs the server's own
tests. The tests start real servers on free loopback ports, and
neither step modifies any files. Run bundle install first, or run
./test.sh true to install dependencies and then test.
Before your own tests rely on a running simulation server, read its
readiness report (GET /__files_mock/v1/ready): contract_version,
schema_sha256 and limits say which server it is and how it was started.
GET /__files_mock/v1/inventory lists every operation of its Swagger
document and whether and how it is simulated.
The Files.com team is happy to help with any issues you may have running the Files.com mock server.
Just email support@files.com and we'll get the process started.