cloudillo_search/format.rs
1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! `/api/doc-formats` — which app indexes a document type, and how.
5//!
6//! # The claim rule
7//!
8//! `(tn_id, content_type)` is a primary key, so exactly one app may *index* a
9//! document type per tenant. Other apps may still open documents of that type;
10//! this is only about who owns the index rules. An upsert succeeds when there
11//! is no active row, when the caller is the same `(publisher_tag, app_name)`
12//! that already holds it, or when the caller is site admin. Anything else is a
13//! [`Error::PermissionDenied`] naming the incumbent.
14//!
15//! Reads are gated too, one step looser: the listing is owner / community leader
16//! / site admin ([`require_format_reader`]), not any authenticated caller. The
17//! full manifest set names every app a tenant runs and everything each of them
18//! indexes, which is the same enumeration [`check_claim`] refuses to put in its
19//! 403 body.
20//!
21//! # Two tiers
22//!
23//! A `doc_formats` row is not the only source. The apps this build bundles ship
24//! their manifests inside `dist`, and
25//! [`cloudillo_core::bundled_apps::BundledAppRegistry`] loads them once at
26//! startup as an in-memory global default — no rows written, nothing duplicated
27//! per tenant. `cloudillo_core::doc_format::resolve` is the choke point every
28//! reader goes through: **tenant row first, bundled entry as the fallback**.
29//!
30//! Only the row is a *claim*. [`check_claim`] is deliberately fed the tenant row
31//! alone, so a tenant installing a different app for a content type this build
32//! bundles is allowed — its row then wins on every read, and `DELETE` reverts to
33//! the bundled default. There is no way to turn a bundled format off, only to
34//! override it.
35//!
36//! # Why apps cannot call this directly
37//!
38//! Apps run in sandboxed iframes with opaque origins and only ever hold
39//! *file-scoped* tokens. [`cloudillo_core::scope::scope_permits`] whitelists
40//! `/api/search` for `TokenScope::File` and nothing else here, so this route is
41//! unreachable with an app's own credential. Registration goes app → shell →
42//! backend, and the shell attests the app's identity from its own window
43//! tracker rather than from the message body. A rogue app therefore cannot
44//! claim a content type it does not own on either side of the boundary.
45//!
46//! # The version gate
47//!
48//! `format_version` is a *document format* version, not an app version: it
49//! describes the search-index contract an app declares for one content type.
50//! `major.minor` is the contract, `patch` the app's counter for compatible
51//! tweaks, and the wire carries the integer encoding `MMMmmmppp` — three decimal
52//! digits per component, so `2.1` patch `42` is `2_001_042` and one `<`
53//! comparison orders two registrations.
54//!
55//! Ordering exists because a registering client does so from every device its
56//! user owns, on every app start. Under last-writer-wins an older build
57//! overwrites a newer one, and since a changed `search` payload drops and
58//! rebuilds the whole content type's index, two devices on different versions
59//! bounce a full tenant reindex between them indefinitely. [`gate`] ignores a
60//! registration older than the stored one.
61//!
62//! Bundled apps do not register at all — their versions are compared inside the
63//! bundle, not on the wire — so the gate covers installed (packaged) apps and
64//! older shells.
65//!
66//! An ignored registration answers `200` with the *stored* row, not `409`. The
67//! shell memoises a success like any other, so a stale device goes quiet after
68//! one call; a `409` would make every tab of that build retry forever.
69//!
70//! Orthogonal to [`crate::rules::SUPPORTED_VERSION`], which is the
71//! platform-owned schema version of the rules DSL itself.
72
73use axum::{
74 Json,
75 extract::{Path, State},
76 http::StatusCode,
77};
78use cloudillo_core::{
79 abac, doc_format,
80 extract::{Auth, IdTag, OptionalRequestId},
81};
82use cloudillo_types::{
83 auth_adapter::AuthCtx,
84 meta_adapter::{DocFormat, UpsertDocFormat},
85 types::ApiResponse,
86};
87use serde::{Deserialize, Serialize};
88
89use crate::prelude::*;
90
91/// Body of `PUT /api/doc-formats/{content_type}`.
92#[derive(Debug, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct PutDocFormat {
95 pub publisher_tag: String,
96 pub app_name: String,
97 /// Encoded document format version, `MMMmmmppp` — three decimal digits for each
98 /// of `major.minor.patch`, so `2.1` patch `42` is `2_001_042`. Registrations are
99 /// ordered by it; see the module docs.
100 pub format_version: Option<i64>,
101 /// Legacy app-version string, accepted and ignored.
102 ///
103 /// `deny_unknown_fields` above would turn an older shell's request into a 400 on
104 /// every app start. Keeping the field is what lets the backend ship before the
105 /// frontend, which is the required deploy order. Remove one release after both
106 /// halves have shipped.
107 pub version: Option<String>,
108 /// `"RTDB"` | `"CRDT"` | `"BLOB"`
109 pub store_tp: Option<String>,
110 /// Deep-link query param name, e.g. `"nav"`.
111 pub nav_param: Option<String>,
112 pub search: Option<serde_json::Value>,
113 pub x: Option<serde_json::Value>,
114}
115
116/// One entry of the listing: a resolved format plus which tier it came from.
117///
118/// `source` is not a column — [`DocFormat`] is an adapter type describing a
119/// `doc_formats` row, and a bundled entry has no row. It is a property of the
120/// *resolution*, so it is attached here rather than pushed down into the adapter.
121#[derive(Debug, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub struct DocFormatEntry {
124 #[serde(flatten)]
125 format: DocFormat,
126 /// `"tenant"` — a row this tenant owns, which an admin may delete to revert.
127 /// `"bundled"` — this build's default, with no row behind it.
128 source: &'static str,
129}
130
131/// `GET /api/doc-formats` — every format in effect for this tenant.
132///
133/// The tenant's own rows, plus each bundled default no row overrides. A node with
134/// an empty `doc_formats` table still lists everything its bundle handles — the
135/// normal state, since bundled apps do not register at runtime.
136pub async fn list_doc_formats(
137 State(app): State<App>,
138 tn_id: TnId,
139 Auth(auth): Auth,
140 IdTag(tenant_id_tag): IdTag,
141 OptionalRequestId(req_id): OptionalRequestId,
142) -> ClResult<(StatusCode, Json<ApiResponse<Vec<DocFormatEntry>>>)> {
143 require_format_reader(&auth, &tenant_id_tag)?;
144 let formats = doc_format::resolve_list(&app, tn_id)
145 .await?
146 .into_iter()
147 .map(|(format, source)| DocFormatEntry { format, source: source.as_str() })
148 .collect();
149 Ok((StatusCode::OK, Json(ApiResponse::new(formats).with_req_id(req_id.unwrap_or_default()))))
150}
151
152/// `PUT /api/doc-formats/{content_type}` — register or update a manifest.
153pub async fn put_doc_format(
154 State(app): State<App>,
155 tn_id: TnId,
156 Auth(auth): Auth,
157 IdTag(tenant_id_tag): IdTag,
158 OptionalRequestId(req_id): OptionalRequestId,
159 Path(content_type): Path<String>,
160 Json(body): Json<PutDocFormat>,
161) -> ClResult<(StatusCode, Json<ApiResponse<Option<DocFormat>>>)> {
162 require_tenant_admin(&auth, &tenant_id_tag)?;
163 if content_type.is_empty() || content_type.len() > 128 {
164 return Err(Error::ValidationError("Invalid content type".into()));
165 }
166 validate_format_version(body.format_version)?;
167 // Reject bad rules at registration rather than discovering them on every
168 // index run, when there is no caller left to tell.
169 if let Some(search) = &body.search {
170 crate::rules::IndexRules::parse(search)?;
171 }
172
173 let existing = app.meta_adapter.read_doc_format(tn_id, &content_type).await?;
174
175 // A registration that restates what this build already bundles writes nothing.
176 // An older shell still registers bundled apps at runtime; without this, every
177 // tenant it touches would get a row duplicating data the process already holds
178 // in memory, permanently shadowing the bundled tier.
179 if existing.is_none()
180 && let Some(bundled) = app.bundled_apps.get(&content_type)
181 && same_as_bundled(bundled, &body)
182 {
183 debug!(content_type, "Doc format registration matches the bundled default; ignoring");
184 return Ok((
185 StatusCode::OK,
186 Json(ApiResponse::new(Some(bundled.clone())).with_req_id(req_id.unwrap_or_default())),
187 ));
188 }
189
190 // `existing` is the tenant row alone, deliberately — a bundled entry is *not*
191 // passed in. A bundled manifest is a default, not a claim, so a tenant
192 // installing a different app for a content type this build happens to bundle
193 // must succeed. Resolving the incumbent through `doc_format::resolve` here
194 // would look natural and would break exactly that case.
195 check_claim(&auth, &content_type, existing.as_ref(), &body)?;
196
197 match gate(existing.as_ref(), &body) {
198 GateDecision::Unchanged => {
199 return Ok((
200 StatusCode::OK,
201 Json(ApiResponse::new(existing).with_req_id(req_id.unwrap_or_default())),
202 ));
203 }
204 GateDecision::Stale => {
205 // 200 with the stored row rather than a 409 — see the module docs.
206 warn!(
207 content_type,
208 stored = ?existing.as_ref().and_then(|e| e.format_version),
209 submitted = ?body.format_version,
210 app = %format!("{}/{}", body.publisher_tag, body.app_name),
211 "Ignored a doc format registration older than the stored one"
212 );
213 return Ok((
214 StatusCode::OK,
215 Json(ApiResponse::new(existing).with_req_id(req_id.unwrap_or_default())),
216 ));
217 }
218 GateDecision::WriteSameVersion => {
219 warn!(
220 content_type,
221 format_version = ?body.format_version,
222 app = %format!("{}/{}", body.publisher_tag, body.app_name),
223 "Doc format rules changed without a formatVersion bump — two builds \
224 sharing a version will re-index this content type against each other"
225 );
226 }
227 GateDecision::Write => {}
228 }
229
230 // Compared as `Option<&Value>` on both sides: comparing `Option<Option<Value>>`
231 // would read a first-ever registration with no `search` as a change and
232 // schedule a content-type sweep for a format that indexes nothing.
233 let rules_changed = existing.as_ref().and_then(|e| e.search.as_ref()) != body.search.as_ref();
234
235 app.meta_adapter
236 .upsert_doc_format(
237 tn_id,
238 &UpsertDocFormat {
239 content_type: &content_type,
240 publisher_tag: &body.publisher_tag,
241 app_name: &body.app_name,
242 format_version: body.format_version,
243 store_tp: body.store_tp.as_deref(),
244 nav_param: body.nav_param.as_deref(),
245 search: body.search.as_ref(),
246 x: body.x.as_ref(),
247 },
248 )
249 .await?;
250
251 // Before anything downstream resolves this content type again — the sweep
252 // scheduled below indexes through `doc_format::resolve` — or the new rules and
253 // nav param would not take effect until restart.
254 doc_format::invalidate(&app, tn_id, &content_type);
255
256 // Rules changed ⇒ every already-indexed document of this type is stale, so the
257 // rows go and the sweep rebuilds them.
258 //
259 // The sweep is scheduled *first*, and a scheduling failure aborts with `?`
260 // before anything is dropped: a sweep over rows still in place is a no-op
261 // rebuild, whereas rows dropped with no sweep persisted stay unfindable until
262 // the weekly `All` cron.
263 //
264 // Only the deep `'D'` rows go, which is all the manifest produced. The files'
265 // own `'F'` rows are server-owned and the content-type sweep does not rebuild
266 // them.
267 if rules_changed {
268 crate::reindex::schedule_content_type(&app, tn_id, &content_type).await?;
269 app.meta_adapter
270 .delete_deep_search_by_content_type(tn_id, &content_type)
271 .await?;
272 }
273
274 let stored = app.meta_adapter.read_doc_format(tn_id, &content_type).await?;
275 Ok((StatusCode::OK, Json(ApiResponse::new(stored).with_req_id(req_id.unwrap_or_default()))))
276}
277
278/// `DELETE /api/doc-formats/{content_type}` — drop the tenant's row.
279///
280/// With a bundled entry behind it this is a *revert*, not a removal: the format
281/// resolves back to what the build ships. A content type the bundle also declares
282/// therefore cannot be turned off, only overridden — see
283/// [`cloudillo_core::bundled_apps`].
284pub async fn delete_doc_format(
285 State(app): State<App>,
286 tn_id: TnId,
287 Auth(auth): Auth,
288 IdTag(tenant_id_tag): IdTag,
289 OptionalRequestId(req_id): OptionalRequestId,
290 Path(content_type): Path<String>,
291) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
292 require_tenant_admin(&auth, &tenant_id_tag)?;
293 if app.meta_adapter.read_doc_format(tn_id, &content_type).await?.is_none() {
294 // Already at the bundled default: nothing to drop, and nothing wrong
295 // either. Only a content type neither tier knows is a 404.
296 if app.bundled_apps.get(&content_type).is_some() {
297 return Ok((
298 StatusCode::OK,
299 Json(ApiResponse::new(()).with_req_id(req_id.unwrap_or_default())),
300 ));
301 }
302 return Err(Error::NotFound);
303 }
304
305 app.meta_adapter.delete_doc_format(tn_id, &content_type).await?;
306 // Same as the PUT path: the entry now resolves to the bundled tier (or to
307 // nothing), and the sweep below reads through `doc_format::resolve`.
308 doc_format::invalidate(&app, tn_id, &content_type);
309 // Dropping an override changes the effective rules exactly as a PUT does, so
310 // what was indexed is stale. Scheduled before the delete, and propagating on
311 // failure, for the reason spelled out in `put_doc_format`.
312 crate::reindex::schedule_content_type(&app, tn_id, &content_type).await?;
313 // Deep parts only: the rules that built them are gone, but the files
314 // themselves are still there and must stay findable by name.
315 app.meta_adapter
316 .delete_deep_search_by_content_type(tn_id, &content_type)
317 .await?;
318
319 Ok((StatusCode::OK, Json(ApiResponse::new(()).with_req_id(req_id.unwrap_or_default()))))
320}
321
322/// Registering or dropping a document format is a tenant-administrative act.
323///
324/// Apps never reach this route at all (`scope_permits` denies it to file
325/// scopes), so the caller is always the shell acting with an unscoped session
326/// credential. Requiring the tenant owner — or SADM — is what stops a federated
327/// visitor, who also reaches this tenant's Host context with a valid token, from
328/// claiming or deleting a content type on a node that is not theirs.
329///
330/// Intended consequence: a user browsing a *remote* node is not that node's
331/// tenant owner, so the shell's `format:register.req` proxy gets a 403 there. A
332/// document's index lives on its owner's node. The shell memoises the 403 for
333/// the session, so the app degrades to file-level hits without retry storms.
334fn require_tenant_admin(auth: &AuthCtx, tenant_id_tag: &str) -> ClResult<()> {
335 if abac::is_admin(auth) || &*auth.id_tag == tenant_id_tag {
336 return Ok(());
337 }
338 Err(Error::PermissionDenied)
339}
340
341/// Owner, community leader, or site admin. Looser than [`require_tenant_admin`] —
342/// reading which formats a tenant registered is a leader-level operation, whereas
343/// claiming one is the owner's alone — but strictly tighter than "any
344/// authenticated caller", which would let a federated visitor enumerate the
345/// tenant's apps and their full index manifests: exactly what [`check_claim`]
346/// withholds from its bare 403.
347///
348/// Checked here rather than by moving the route into `protected.rs`'s
349/// `require_leader` group: `SADM` is not part of `roles::ROLE_HIERARCHY`, so that
350/// group would lock a site admin out of the sibling `PUT`/`DELETE` routes
351/// [`require_tenant_admin`] deliberately admits them to.
352fn require_format_reader(auth: &AuthCtx, tenant_id_tag: &str) -> ClResult<()> {
353 if abac::is_admin(auth)
354 || &*auth.id_tag == tenant_id_tag
355 || cloudillo_core::roles::is_leader(&auth.roles)
356 {
357 return Ok(());
358 }
359 Err(Error::PermissionDenied)
360}
361
362/// Enforce the claim rule described in the module docs.
363///
364/// Applied *in addition* to [`require_tenant_admin`] on the upsert path: the
365/// tenant owner has the authority to register formats, but not to silently steal
366/// a claim another app already holds.
367fn check_claim(
368 auth: &AuthCtx,
369 content_type: &str,
370 existing: Option<&DocFormat>,
371 body: &PutDocFormat,
372) -> ClResult<()> {
373 if abac::is_admin(auth) {
374 return Ok(());
375 }
376 let Some(existing) = existing else { return Ok(()) };
377 if claimed_by(existing, &body.publisher_tag, &body.app_name) {
378 return Ok(());
379 }
380 // The incumbent's identity is logged rather than returned: the response is a
381 // bare 403 so a probing app cannot enumerate which apps a tenant runs.
382 warn!(
383 content_type,
384 claimant = %format!("{}/{}", existing.publisher_tag, existing.app_name),
385 challenger = %format!("{}/{}", body.publisher_tag, body.app_name),
386 "Rejected doc format claim by a different app"
387 );
388 Err(Error::PermissionDenied)
389}
390
391fn claimed_by(existing: &DocFormat, publisher_tag: &str, app_name: &str) -> bool {
392 &*existing.publisher_tag == publisher_tag && &*existing.app_name == app_name
393}
394
395/// Whether a registration would write a row saying exactly what the bundle
396/// already says.
397///
398/// Everything a `doc_formats` row carries except `x` — which the bundle has no
399/// way to express, so a body naming one is a genuine difference and must write.
400/// `updated_at` is not compared: it is when the row (or the manifest file) was
401/// last touched, not part of what either declares.
402fn same_as_bundled(bundled: &DocFormat, body: &PutDocFormat) -> bool {
403 *bundled.publisher_tag == body.publisher_tag
404 && *bundled.app_name == body.app_name
405 && bundled.format_version == body.format_version
406 && bundled.store_tp.as_deref() == body.store_tp.as_deref()
407 && bundled.nav_param.as_deref() == body.nav_param.as_deref()
408 && bundled.search.as_ref() == body.search.as_ref()
409 && body.x.is_none()
410}
411
412/// Every field a registration can change, compared. `format_version` is not
413/// here — the only caller has already established the two are equal.
414///
415/// [`gate`] returning [`GateDecision::Unchanged`] means "the stored row already
416/// says exactly this", so it has to mean *every* field, not just `search`: an app
417/// that edits `nav_param` or `store_tp` without bumping its version would
418/// otherwise get a 200 and no write, and every deep link would keep using the
419/// stale param.
420fn same_content(existing: &DocFormat, body: &PutDocFormat) -> bool {
421 *existing.publisher_tag == body.publisher_tag
422 && *existing.app_name == body.app_name
423 && existing.store_tp.as_deref() == body.store_tp.as_deref()
424 && existing.nav_param.as_deref() == body.nav_param.as_deref()
425 && existing.search.as_ref() == body.search.as_ref()
426 && existing.x.as_ref() == body.x.as_ref()
427}
428
429/// Largest encodable format version, `999.999.999`.
430const FORMAT_VERSION_MAX: i64 = 999_999_999;
431
432/// Reject a version that cannot have come from the documented encoding.
433fn validate_format_version(format_version: Option<i64>) -> ClResult<()> {
434 match format_version {
435 Some(v) if !(0..=FORMAT_VERSION_MAX).contains(&v) => {
436 Err(Error::ValidationError("Invalid formatVersion".into()))
437 }
438 _ => Ok(()),
439 }
440}
441
442/// What the version gate decided about one registration.
443#[derive(Debug, PartialEq, Eq)]
444enum GateDecision {
445 /// Persist it.
446 Write,
447 /// Persist it, but the caller reused a version for different rules.
448 WriteSameVersion,
449 /// The stored row already says exactly this. 200, nothing written.
450 Unchanged,
451 /// Older than what is stored. 200 with the stored row, nothing written.
452 Stale,
453}
454
455/// Decide whether a registration should touch the database.
456///
457/// Pure, so every branch is unit-testable. See the module docs for why
458/// registrations have to be ordered at all.
459fn gate(existing: Option<&DocFormat>, body: &PutDocFormat) -> GateDecision {
460 // Nothing to order against.
461 let Some(existing) = existing else { return GateDecision::Write };
462
463 // A NULL stored version predates the integer encoding, or came from a client
464 // that had none. It carries no ordering, so it must never block a write —
465 // otherwise a migrated row would be frozen forever.
466 let Some(stored) = existing.format_version else { return GateDecision::Write };
467
468 // A caller that states no version cannot claim to be newer than one that did.
469 // Without this an old client clobbers `format_version` back to NULL on every
470 // session, restoring the ping-pong.
471 let Some(submitted) = body.format_version else { return GateDecision::Stale };
472
473 if submitted < stored {
474 return GateDecision::Stale;
475 }
476 if submitted > stored {
477 return GateDecision::Write;
478 }
479
480 // Equal. The common case by far is every open tab of one build re-registering
481 // identical rules, which must not write.
482 if same_content(existing, body) {
483 GateDecision::Unchanged
484 } else {
485 GateDecision::WriteSameVersion
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 fn auth(id_tag: &str, roles: &[&str]) -> AuthCtx {
494 AuthCtx {
495 tn_id: TnId(1),
496 id_tag: id_tag.into(),
497 roles: roles.iter().map(|r| (*r).into()).collect(),
498 scope: None,
499 anonymous: false,
500 }
501 }
502
503 /// The whole read/write matrix for these two routes, in one table.
504 ///
505 /// Both halves matter and neither implies the other: a valid `AuthCtx` is not
506 /// enough to *write* — a stranger reaching this tenant's Host context must not
507 /// be able to claim or drop a content type — and the listing they would *read*
508 /// names every app the tenant runs and everything each of them indexes, which
509 /// is exactly the enumeration `check_claim` refuses to put in its 403 body.
510 #[test]
511 fn reading_formats_is_one_step_looser_than_writing_them() {
512 // (who, may read, may write)
513 let cases = [
514 ("the tenant owner", auth("alice.example", &[]), true, true),
515 ("a site admin", auth("root.example", &["SADM"]), true, true),
516 ("a community leader", auth("bob.example", &["leader"]), true, false),
517 // A member below leader is still a visitor as far as this route goes.
518 ("a contributor", auth("carol.example", &["contributor"]), false, false),
519 ("a federated visitor", auth("mallory.example", &[]), false, false),
520 ];
521 for (who, ctx, may_read, may_write) in cases {
522 let read = require_format_reader(&ctx, "alice.example");
523 let write = require_tenant_admin(&ctx, "alice.example");
524 assert_eq!(read.is_ok(), may_read, "{who} read: {read:?}");
525 assert_eq!(write.is_ok(), may_write, "{who} write: {write:?}");
526 // The denial has to be a 403, not some other error standing in for one.
527 if !may_read {
528 assert!(matches!(read, Err(Error::PermissionDenied)), "{who}: {read:?}");
529 }
530 if !may_write {
531 assert!(matches!(write, Err(Error::PermissionDenied)), "{who}: {write:?}");
532 }
533 }
534 }
535
536 fn rules(title: &str) -> serde_json::Value {
537 serde_json::json!({ "v": 1, "parts": [{ "kind": "p", "title": [title] }] })
538 }
539
540 /// A stored row, as `read_doc_format` would return it.
541 fn stored(format_version: Option<i64>, search: Option<serde_json::Value>) -> DocFormat {
542 DocFormat {
543 content_type: "cloudillo/notillo".into(),
544 publisher_tag: "cloudillo.org".into(),
545 app_name: "notillo".into(),
546 format_version,
547 store_tp: Some("RTDB".into()),
548 nav_param: Some("nav".into()),
549 search,
550 x: None,
551 updated_at: Timestamp(0),
552 }
553 }
554
555 /// An incoming registration.
556 fn put(format_version: Option<i64>, search: Option<serde_json::Value>) -> PutDocFormat {
557 PutDocFormat {
558 publisher_tag: "cloudillo.org".into(),
559 app_name: "notillo".into(),
560 format_version,
561 version: None,
562 store_tp: Some("RTDB".into()),
563 nav_param: Some("nav".into()),
564 search,
565 x: None,
566 }
567 }
568
569 /// Every ordering branch of [`gate`], as one table: `(why, stored row,
570 /// submitted body, decision)`. The `why` is what a failure prints, so a broken
571 /// branch still names itself.
572 #[test]
573 fn the_write_gate_orders_registrations_by_version() {
574 use GateDecision::{Stale, Unchanged, Write, WriteSameVersion};
575
576 let (v0, v1) = (Some(1_000_000), Some(1_001_000));
577 let ti = || Some(rules("ti"));
578 let tj = || Some(rules("tj"));
579 let cases: Vec<(&str, Option<DocFormat>, PutDocFormat, GateDecision)> = vec![
580 ("a first registration has nothing to order against", None, put(v0, ti()), Write),
581 // Rows migrated off the old TEXT `version` column land here. Treating
582 // NULL as an ordering would freeze them forever.
583 (
584 "a NULL stored version carries no ordering",
585 Some(stored(None, ti())),
586 put(v0, ti()),
587 Write,
588 ),
589 // An old client would otherwise clobber `format_version` back to NULL on
590 // every session, restoring the reindex ping-pong (see `gate`).
591 (
592 "a caller stating no version cannot outrank one that did",
593 Some(stored(v0, ti())),
594 put(None, ti()),
595 Stale,
596 ),
597 ("an older registration is ignored", Some(stored(v1, ti())), put(v0, ti()), Stale),
598 ("a newer registration writes", Some(stored(v0, ti())), put(v1, tj()), Write),
599 // Every open tab of one build takes this path on startup.
600 (
601 "the same version restating the same rules writes nothing",
602 Some(stored(v0, ti())),
603 put(v0, ti()),
604 Unchanged,
605 ),
606 // A developer editing rules without bumping. It still writes — refusing
607 // would break that loop — but it is the one path that can still bounce.
608 (
609 "the same version with different rules still writes",
610 Some(stored(v0, ti())),
611 put(v0, tj()),
612 WriteSameVersion,
613 ),
614 ];
615 for (why, existing, body, expected) in cases {
616 assert_eq!(gate(existing.as_ref(), &body), expected, "{why}");
617 }
618 }
619
620 #[test]
621 fn the_same_version_with_a_changed_non_rule_field_still_writes() {
622 // `Unchanged` claims the stored row already says exactly this, so every
623 // field a registration can change has to be compared — not just `search`.
624 // An app editing `nav_param` without bumping its version would otherwise
625 // leave every deep link built from the stale param.
626 let v = Some(1_000_000);
627 let existing = stored(v, Some(rules("ti")));
628
629 let nav = PutDocFormat { nav_param: Some("page".into()), ..put(v, Some(rules("ti"))) };
630 assert_eq!(gate(Some(&existing), &nav), GateDecision::WriteSameVersion);
631
632 let store = PutDocFormat { store_tp: Some("CRDT".into()), ..put(v, Some(rules("ti"))) };
633 assert_eq!(gate(Some(&existing), &store), GateDecision::WriteSameVersion);
634
635 let x = PutDocFormat {
636 x: Some(serde_json::json!({ "icon": "note" })),
637 ..put(v, Some(rules("ti")))
638 };
639 assert_eq!(gate(Some(&existing), &x), GateDecision::WriteSameVersion);
640
641 let app = PutDocFormat { app_name: "notillo2".into(), ..put(v, Some(rules("ti"))) };
642 assert_eq!(gate(Some(&existing), &app), GateDecision::WriteSameVersion);
643 }
644
645 #[test]
646 fn a_registration_restating_the_bundled_default_writes_nothing() {
647 // The path an older shell — or any app that still registers — takes. Without
648 // it every tenant such a client touches grows a row duplicating what the
649 // process already holds in memory, permanently shadowing the bundled tier.
650 let bundled = stored(Some(1_000_000), Some(rules("ti")));
651 assert!(same_as_bundled(&bundled, &put(Some(1_000_000), Some(rules("ti")))));
652 }
653
654 #[test]
655 fn anything_the_bundle_does_not_already_say_still_writes() {
656 let bundled = stored(Some(1_000_000), Some(rules("ti")));
657
658 // Different rules, or a different version of them.
659 assert!(!same_as_bundled(&bundled, &put(Some(1_000_000), Some(rules("tj")))));
660 assert!(!same_as_bundled(&bundled, &put(Some(1_001_000), Some(rules("ti")))));
661
662 // A different app entirely — the override case, which must reach the write.
663 let mut other_app = put(Some(1_000_000), Some(rules("ti")));
664 other_app.app_name = "otherillo".into();
665 assert!(!same_as_bundled(&bundled, &other_app));
666
667 // `x` has no bundled counterpart, so a body carrying one always differs.
668 let mut with_x = put(Some(1_000_000), Some(rules("ti")));
669 with_x.x = Some(serde_json::json!({ "k": 1 }));
670 assert!(!same_as_bundled(&bundled, &with_x));
671
672 // A field the bundle states and the body does not.
673 let mut no_nav = put(Some(1_000_000), Some(rules("ti")));
674 no_nav.nav_param = None;
675 assert!(!same_as_bundled(&bundled, &no_nav));
676 }
677
678 #[test]
679 fn a_bundled_entry_does_not_block_a_tenants_own_claim() {
680 // A bundled manifest is a default, not a claim. `check_claim` is fed the
681 // tenant row only (`None` here, since the content type resolves through the
682 // bundle), so a tenant installing a different app for a content type this
683 // build bundles is allowed — and its row then wins on every read.
684 let owner = auth("alice.example", &[]);
685 let mut challenger = put(Some(1_000_000), Some(rules("tj")));
686 challenger.publisher_tag = "other.example".into();
687 challenger.app_name = "otherillo".into();
688
689 assert!(check_claim(&owner, "cloudillo/notillo", None, &challenger).is_ok());
690 }
691
692 #[test]
693 fn the_encoding_bounds_are_accepted_and_anything_outside_them_is_not() {
694 assert!(validate_format_version(None).is_ok());
695 assert!(validate_format_version(Some(0)).is_ok());
696 assert!(validate_format_version(Some(999_999_999)).is_ok());
697 assert!(matches!(validate_format_version(Some(-1)), Err(Error::ValidationError(_))));
698 assert!(matches!(
699 validate_format_version(Some(1_000_000_000)),
700 Err(Error::ValidationError(_))
701 ));
702 }
703}
704
705// vim: ts=4