Skip to main content

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