From 0eec5095f8da2b78d164b0b6eefb4e099163046c Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Thu, 10 Sep 2026 12:24:53 +0100 Subject: [PATCH 01/17] refactor(banners): Replace the payment-failure evaluator with a command intake The stack was reading Stripe subscription statuses and attempt counts and deciding from them what to tell the user. That is billing logic, and it does not belong here: the stack should write banners, not decide them. billing.go, the BillingLifecycleMessage contract, its handler, its queue and the four billing translations are gone. In their place, a backend publishes a banner.materialize or banner.clear command carrying the wording it wants, and the stack validates it and stores it. What the stack still decides: - The category must be allowed for the instance context, through banner_command_categories. The quota category is always refused, because the stack measures disk usage itself. - The routing key decides materialize versus clear, never the payload. - The CTA must be an absolute https URL, the payload must fit MaxCommandBytes, and the bannerId must match the doctype's format. Ordering no longer rides on the visible document. A command carries a revision, and the last accepted one per category is kept in io.cozy.banners.commands, blocklisted so no application can touch it. An app with dismissal rights on io.cozy.banners could otherwise rewind the ordering by rewriting the document it is allowed to write. Delivery is at-least-once and unordered: a stale revision is refused and the same revision applied twice converges, so a redelivery is a no-op. A command that cannot be fixed by retrying is nacked and the broker dead letters it after delivery_limit attempts. docs/banners.md describes the contract for the producing side. --- assets/locales/en.po | 15 - assets/locales/fr.po | 12 - assets/locales/ru.po | 12 - assets/locales/vi.po | 12 - cozy.example.yaml | 34 + docs/README.md | 1 + docs/banners.md | 238 ++++ docs/rabbitmq.md | 24 + model/banner/banner_test.go | 59 +- model/banner/billing.go | 130 -- model/banner/command.go | 404 +++++++ model/banner/command_io_test.go | 94 ++ model/banner/command_test.go | 667 ++++++++++ model/banner/testdata/clear.json | 7 + model/banner/testdata/materialize.json | 36 + model/banner/testdata/organization.json | 27 + model/instance/instance.go | 19 + model/permission/doctype.go | 1 + pkg/consts/doctype.go | 3 + pkg/rabbitmq/banner_commands_test.go | 184 +++ pkg/rabbitmq/contracts.go | 33 +- pkg/rabbitmq/handlers.go | 78 +- pkg/rabbitmq/handlers_test.go | 119 ++ pkg/rabbitmq/rabbitmq.go | 4 +- web/data/banner_commands_test.go | 69 ++ web/statik/statik.go | 1470 +++++++++++------------ 26 files changed, 2706 insertions(+), 1046 deletions(-) create mode 100644 docs/banners.md delete mode 100644 model/banner/billing.go create mode 100644 model/banner/command.go create mode 100644 model/banner/command_io_test.go create mode 100644 model/banner/command_test.go create mode 100644 model/banner/testdata/clear.json create mode 100644 model/banner/testdata/materialize.json create mode 100644 model/banner/testdata/organization.json create mode 100644 pkg/rabbitmq/banner_commands_test.go create mode 100644 web/data/banner_commands_test.go diff --git a/assets/locales/en.po b/assets/locales/en.po index 491a602dda4..e551e55aa6a 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1345,18 +1345,3 @@ msgstr "AI Assistant" msgid "Banners Quota Exceeded Text" msgstr "You have reached your storage limit." - - -msgid "Banners Billing Restricted Text" -msgstr "We couldn't process your payment after several attempts. Some features are now limited for your organization and your team may not be able to perform certain actions.\n\nYour data remains safe and unchanged. Full access will be restored instantly once your payment is updated." - -msgid "Banners Billing CTA Label" -msgstr "Update payment method" - - - -msgid "Banners Billing Restricted Title" -msgstr "Your workspace is temporarily restricted" - -msgid "Banners Billing Support Label" -msgstr "Contact support" diff --git a/assets/locales/fr.po b/assets/locales/fr.po index 35d64020a3f..96e47e014d0 100644 --- a/assets/locales/fr.po +++ b/assets/locales/fr.po @@ -1458,15 +1458,3 @@ msgstr "Voir les détails du fichier" msgid "Banners Quota Exceeded Text" msgstr "Vous avez atteint la limite de votre espace de stockage." - -msgid "Banners Billing Restricted Title" -msgstr "Votre espace de travail est temporairement restreint" - -msgid "Banners Billing Restricted Text" -msgstr "Nous n'avons pas pu traiter votre paiement après plusieurs tentatives. Certaines fonctionnalités sont désormais limitées pour votre organisation et votre équipe peut ne plus pouvoir effectuer certaines actions.\n\nVos données restent intactes et en sécurité. L'accès complet sera rétabli dès la mise à jour de votre paiement." - -msgid "Banners Billing CTA Label" -msgstr "Mettre à jour le moyen de paiement" - -msgid "Banners Billing Support Label" -msgstr "Contacter le support" diff --git a/assets/locales/ru.po b/assets/locales/ru.po index da8de2db7e8..3fa129e677e 100644 --- a/assets/locales/ru.po +++ b/assets/locales/ru.po @@ -1406,15 +1406,3 @@ msgstr "Someone shared a folder with you:" msgid "Banners Quota Exceeded Text" msgstr "Вы достигли лимита хранилища." - -msgid "Banners Billing Restricted Title" -msgstr "Ваше рабочее пространство временно ограничено" - -msgid "Banners Billing Restricted Text" -msgstr "Нам не удалось обработать ваш платёж после нескольких попыток. Некоторые функции теперь ограничены для вашей организации, и ваша команда может не иметь возможности выполнять определённые действия.\n\nВаши данные в безопасности и не изменены. Полный доступ будет восстановлен сразу после обновления платежа." - -msgid "Banners Billing CTA Label" -msgstr "Обновить способ оплаты" - -msgid "Banners Billing Support Label" -msgstr "Связаться со службой поддержки" diff --git a/assets/locales/vi.po b/assets/locales/vi.po index 030606bb88e..adf59bfe779 100644 --- a/assets/locales/vi.po +++ b/assets/locales/vi.po @@ -1378,15 +1378,3 @@ msgstr "Someone shared a folder with you:" msgid "Banners Quota Exceeded Text" msgstr "Bạn đã đạt đến giới hạn dung lượng lưu trữ." - -msgid "Banners Billing Restricted Title" -msgstr "Không gian làm việc của bạn tạm thời bị hạn chế" - -msgid "Banners Billing Restricted Text" -msgstr "Chúng tôi không thể xử lý khoản thanh toán của bạn sau nhiều lần thử. Một số tính năng hiện bị hạn chế đối với tổ chức của bạn và nhóm của bạn có thể không thực hiện được một số thao tác.\n\nDữ liệu của bạn vẫn an toàn và không thay đổi. Quyền truy cập đầy đủ sẽ được khôi phục ngay khi khoản thanh toán của bạn được cập nhật." - -msgid "Banners Billing CTA Label" -msgstr "Cập nhật phương thức thanh toán" - -msgid "Banners Billing Support Label" -msgstr "Liên hệ bộ phận hỗ trợ" diff --git a/cozy.example.yaml b/cozy.example.yaml index 088ccf30d1e..5bb28774d63 100644 --- a/cozy.example.yaml +++ b/cozy.example.yaml @@ -535,6 +535,19 @@ contexts: # Feature flags features: - hide_konnector_errors + # Materialize the platform banners (io.cozy.banners) for the instances of + # this context. Off by default, so the rules can ship before the clients + # that render them. Turning it back off stops the writes and leaves the + # documents already materialized in place. + enable_banners: true + # The banner categories the stack.banner.commands queue is allowed to + # write. Its publisher is authenticated by its broker credentials and + # bindings, so this says what it may say, not who it is. The quota + # category is always refused: the stack measures disk usage itself. + # See docs/banners.md. + banner_command_categories: + - billing + - trial # List of applications that can be automatically updated even if the # permissions have changed additional_platform_apps: @@ -746,3 +759,24 @@ rabbitmq: delivery_limit: 5 bindings: - app.installation.requested + # Banner commands. The exchange is a deployment choice: an existing one + # with dedicated bindings works too, as long as both repositories name the + # same one. The dead letter queue is where a malformed or unauthorized + # command lands, so it has to exist for those to be inspectable. + - name: platform + kind: topic + durable: true + declare_exchange: false + queues: + - name: stack.banner.commands + declare: true + declare_dlx: true + declare_dlq: true + dlx_name: stack.platform.dlx + dlq_name: stack.dead.letter.banner.commands + dl_routing_key: banner.commands.dead + prefetch: 8 + delivery_limit: 5 + bindings: + - banner.materialize + - banner.clear diff --git a/docs/README.md b/docs/README.md index c2b49030de0..d6a2b3b1ab6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ Feel free to [open an issue](https://github.com/cozy/cozy-stack/issues/new) for ### Up-to-date +- [Banners](banners.md) - [Flagship app](flagship.md) - [Move design](move-design.md) - [Realtime internals](realtime-internals.md) diff --git a/docs/banners.md b/docs/banners.md new file mode 100644 index 00000000000..806fc01bcee --- /dev/null +++ b/docs/banners.md @@ -0,0 +1,238 @@ +## Banners + +A banner is a platform message displayed to the user by their applications: a +quota warning, a payment problem, a trial about to end. The stack stores one +`io.cozy.banners` document per category and the clients render whatever they +find. There is no computation behind a read: the rules run when an input +changes and the result is written to the instance database. + +Banners are off unless the instance context enables them: + +```yaml +contexts: + b2b_twake_default: + enable_banners: true +``` + +Turning the switch back off stops the writes and leaves the documents already +materialized in place, so a rollback needs a cleanup too. + +### Producers + +Two kinds of producer write the same documents through the same code: + +- **In-process rules**, for what the stack owns. Disk usage is the only one + today (`model/banner/quota.go`): the stack measures it, so the stack decides, + and the wording comes from its own locale catalogs. +- **A backend on the bus**, for what the stack cannot verify. A payment status, + a dunning step, a trial conversion are decisions another service already + made, so they arrive as commands carrying their own wording. The stack + validates, targets, orders, localizes and stores them; it decides nothing + about what they say. + +A producer never addresses a document. It names a category, and the stack does +the rest. `quota` is reserved to the in-process rules and a command naming it +is rejected. + +### The command contract + +Commands are consumed from the `stack.banner.commands` queue with two routing +keys. See [the RabbitMQ documentation](rabbitmq.md) for the queue declaration. +The fixtures in `model/banner/testdata` are the shared examples the publisher +is developed against. + +**`banner.materialize`** puts a banner in a category, replacing whatever that +category holds: + +```json +{ + "workplaceFqdn": "alice.twake.app", + "eventId": "banner-command-42", + "revision": 42, + "timestamp": 1788944400, + "category": "billing", + "bannerId": "billing.grace.cycle-a.attempt-2", + "severity": "warning", + "surface": "banner", + "priority": 150, + "dismissible": true, + "title": { "en": "Payment failed", "fr": "Échec du paiement" }, + "text": { "en": "We could not charge your card.", "fr": "Nous n'avons pas pu débiter votre carte." }, + "cta": { + "label": { "en": "Update payment method", "fr": "Mettre à jour le moyen de paiement" }, + "url": "https://manager.example.org/linagora/twake_prod/premium" + }, + "secondaryCta": { + "label": { "en": "Contact support", "fr": "Contacter le support" }, + "url": "https://twake.app/support" + }, + "startsAt": "2026-08-01T00:00:00Z", + "endsAt": "2026-08-05T23:30:00Z" +} +``` + +**`banner.clear`** empties a category. It carries the addressing and ordering +fields only. Nonempty presentation fields are rejected: + +```json +{ + "workplaceFqdn": "alice.twake.app", + "eventId": "banner-command-43", + "revision": 43, + "timestamp": 1788944400, + "category": "billing" +} +``` + +| Field | Required | Notes | +| --- | --- | --- | +| `category` | always | The slot to write. One document per category per instance. `quota` is refused. | +| `workplaceFqdn` | one of the two | A single instance. | +| `domain` | one of the two | A B2B organization: every instance under it gets the banner. | +| `revision` | always | A positive counter the backend increments per target and category. It is what orders commands. | +| `timestamp` | always | Positive epoch seconds representable in RFC3339, when the backend decided. Provenance, stamped on the document; it orders nothing. | +| `eventId` | no | The backend's correlation id, at most 256 bytes. Logged and retained, never a second ordering mechanism. | +| `bannerId` | materialize | Identifies the occurrence: a new one clears a dismissal, the same one keeps it. | +| `severity` | materialize | `info`, `warning` or `error`. | +| `surface` | materialize | `banner` or `modal`. | +| `text` | materialize | A map keyed by locale, complete in `en`. | +| `title` | no | Same shape as `text`. A client with no title names the dialog from the text. | +| `cta`, `secondaryCta` | no | `url` must be an absolute `https` URL. A secondary action needs a primary one. | +| `dismissible` | no | Defaults to false. A modal with neither a call to action nor a dismissal is made dismissible. | +| `priority` | no | 0 to 1000. The stack's own quota banners sit at 50 and 100. | +| `startsAt`, `endsAt` | no | RFC 3339. `startsAt` defaults to the decision time. | + +The document also carries `source.trigger`, which is `banner.command` for +everything that arrives this way, and `cozyMetadata.createdByApp`, which stays +`stack` whoever asked: a client cannot be made to reason about a per-producer +author. `_id`, `_rev`, `dismissedAt` and `cozyMetadata` are not fields of the +command and a payload carrying them is ignored, not honored. + +### Localization + +`text`, `title` and every label are rendered by the backend, not by the stack. +The stack picks **one** locale for the whole banner: the instance's, if every +string the document needs exists in it, and `en` otherwise. `lang` names the +language the user actually reads. Falling back field by field would put a +French sentence above an English button. + +The stack keeps no copy of the locales it did not use, so changing an +instance's language leaves a commanded banner in the language it was +materialized in until the backend publishes its next command. Only the banners +the stack writes itself are reworded on a language change. + +The languages available for a commanded banner are the ones the backend sends, +not the stack's `consts.SupportedLocales`: the stack renders nothing here, so +its own catalogs have no say. Those catalogs still decide the languages of what +the stack does write itself, the quota banners, and shipping a `.po` file is +not what enables one. + +### Validation + +The command is rejected, never repaired. An authorized backend can put +arbitrary text in front of a user, so anything unexpected in a payload is a +backend bug worth surfacing rather than something to guess at. A rejected +command fails the delivery, so the broker redelivers it up to the queue's +`delivery_limit` and then dead letters it. + +- `category` matches `^[a-z][a-z0-9-]{0,31}$` and is not `quota`. +- exactly one of `domain` and `workplaceFqdn`, each a plain host name. +- `revision` and `timestamp` are above zero; the timestamp must serialize as an + RFC3339 time (milliseconds sent as seconds are rejected). +- `bannerId` matches `^[a-z0-9.-]{1,64}$`. +- `severity` is one of `info`, `warning`, `error`. +- `surface` is one of `banner`, `modal`. +- `priority` is between 0 and 1000; the effective start (`startsAt`, or + `timestamp` when omitted) is before `endsAt`. Window values must be + representable in RFC3339. +- `text`, `title` and every label are present in the `en` fallback locale. +- a call to action has an absolute `https` URL, and a secondary one has a + primary alongside it. +- lengths, in bytes, per locale: 256 for a title, 1024 for a text, 128 for a + label, 2048 for a URL, 256 for `eventId`; at most 32 locales per map, + with locale keys of 1–35 bytes. The JSON command is limited to 256 KiB, + including whitespace and unknown fields at the transport boundary. +- clear commands reject nonempty presentation fields, including wording and + windows; their addressing, timestamp and correlation fields are still validated. + +An instance whose context has no `enable_banners` is a no-op rather than a +rejection: the backend knows its customers, not which of them display banners. +A workplace that is not here is retried rather than rejected, because the stack +cannot tell a deleted instance from one still being provisioned; the queue's +delivery limit is what bounds those retries. + +### Authorization + +The queue is the authority on who publishes: broker credentials, permissions +and bindings, not a field of the payload. The context settings say what that +publisher is allowed to say: + +```yaml +contexts: + b2b_twake_default: + enable_banners: true + banner_command_categories: + - billing + - trial +``` + +**One category, one owner.** Two producers writing the same category means last +writer wins by counters that were never comparable. Scopes that can be active +at the same time need separate categories. + +A command for a category an enabled member context does not list is rejected +**before any member banner or revision record is written**. Authorization is +checked over the full resolved recipient list first. Repair the configuration +and explicitly replay a rejected command once it has been dead lettered. +Storage failures during fan-out are retried by the broker, and a replay of the +same revision finishes the members that were not reached, because nothing is +recorded for a member whose banner was not written. + +### Ordering and retries + +Bus delivery is at-least-once and unordered, so ordering cannot come from the +arrival time, and it cannot come from the visible document either: a clear +leaves none behind and an unchanged decision writes none. The stack keeps the +last accepted revision per instance and category in `io.cozy.banners.commands`, +a separate doctype blocked from public reads and writes, including wildcard +application grants and the bulk/replication API, so an application cannot +rewrite the ordering record. It is a normal document, so it is included in the +instance's backups and migrations. + +Under the instance's banner lock: + +1. A command whose revision is not above the recorded one is ignored. That + covers a redelivery and a stale command alike, including a revision the + backend reused with different wording, which is a backend bug the stack + cannot repair. +2. Otherwise the banner is written first and the record second. A process that + dies between the two leaves the next delivery of that revision to do both + again, and materialization is idempotent, so it heals itself. The reverse + order would record a decision the user never saw. + +This is what makes a clear survive a redelivered materialize, an unchanged +decision advance the ordering, and a partial organization fan-out finish on the +retry. A retry reuses the revision, correlation id and payload of the original; +only a changed decision needs a new revision. The backend allocates them, and +must serialize its own state refresh so a newer revision never carries an older +snapshot. + +Re-publishing an unchanged organization command at its existing revision is how +a member provisioned after the fact is reached: the replay resolves membership +again and leaves the members it already reached untouched. + +### Dismissals and occurrences + +Re-materializing the same `bannerId` keeps a dismissal the user recorded, and +keeps the moment the occurrence began rather than the last evaluation. A new +`bannerId` is a message the user has not seen, so it clears the dismissal. +Escalating a dunning cycle, or starting a new one, is a new occurrence; changing +the wording of the current one is not. + +### What the stack does not do + +Nothing replays on its own: enabling `enable_banners` on a context materializes +nothing until the next command, and a backend that needs its banners to appear +has to publish them again. The stack sends no acknowledgement back: a broker +confirm means the broker accepted the message, not that any instance displays +it. diff --git a/docs/rabbitmq.md b/docs/rabbitmq.md index 9dd0a532c25..7828e68b95a 100644 --- a/docs/rabbitmq.md +++ b/docs/rabbitmq.md @@ -149,6 +149,23 @@ rabbitmq: delivery_limit: 5 bindings: - app.installation.requested + - name: platform + kind: topic + durable: true + declare_exchange: false + queues: + - name: stack.banner.commands + declare: true + declare_dlx: true + declare_dlq: true + dlx_name: stack.platform.dlx + dlq_name: stack.dead.letter.banner.commands + dl_routing_key: banner.commands.dead + prefetch: 8 + delivery_limit: 5 + bindings: + - banner.materialize + - banner.clear ``` ### Dead Letter Exchange (DLX) and Dead Letter Queue (DLQ) @@ -267,12 +284,19 @@ type Handler interface { Returning `nil` acknowledges the message. Returning a non-nil error causes the message to be requeued (subject to broker policies and delivery limits). +A handler does not classify its errors. Every failure is nacked with requeue, +and the queue's `delivery_limit` is what bounds the retries: once it is reached +the broker dead letters the message. So a payload that does not parse costs a +few redeliveries before it lands in the dead letter queue, and a storage failure +gets those same attempts to succeed. + Queue names are mapped to handlers in the stack. For example: - `user.password.updated` → updates an instance passphrase when a `user.password.updated` routing key is received. - `user.created` → validates and processes user creation events. - `user.phone.updated` → updates the phone number stored in user settings. - `domain.user.deleted` on the `b2b` exchange → removes externally managed organization contacts. +- `banner.materialize` and `banner.clear` on the `platform` exchange → materializes or clears a platform banner, see [Banners](banners.md). Message schemas are JSON and validated in the handler. Example payload for `user.password.updated`: diff --git a/model/banner/banner_test.go b/model/banner/banner_test.go index 518ee12e5c0..61aef0b4746 100644 --- a/model/banner/banner_test.go +++ b/model/banner/banner_test.go @@ -1,18 +1,22 @@ package banner import ( + "context" "os" "testing" "time" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/i18n" "github.com/cozy/cozy-stack/pkg/metadata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestMain loads the real catalogs, so a message id that no longer exists -// fails here rather than rendering its own name to a user. +// TestMain loads the real catalogs so a stale message id breaks the test, not +// production, and brings up the global database (inlined from testutils to +// avoid a circular import). func TestMain(m *testing.M) { for _, locale := range []string{"en", "fr"} { po, err := os.ReadFile("../../assets/locales/" + locale + ".po") @@ -21,6 +25,15 @@ func TestMain(m *testing.M) { } i18n.LoadLocale(locale, "", po) } + if err := config.LoadTestFile(); err != nil { + panic(err) + } + ctx := context.Background() + if _, err := couchdb.CheckStatus(ctx); err == nil { + if err := couchdb.InitGlobalDB(ctx); err != nil { + panic(err) + } + } os.Exit(m.Run()) } @@ -311,45 +324,3 @@ func TestAModalAlwaysHasAWayOut(t *testing.T) { assert.False(t, b.Dismissible, "a banner does not cover the application") }) } - -func TestEvaluateBilling(t *testing.T) { - state := func(status string) BillingState { - return BillingState{Status: status, Locale: "en"} - } - - t.Run("no banner while the subscription is paying", func(t *testing.T) { - assert.Nil(t, EvaluateBilling(state("active"), now)) - assert.Nil(t, EvaluateBilling(state("trialing"), now)) - }) - - t.Run("no banner while Stripe is still retrying", func(t *testing.T) { - assert.Nil(t, EvaluateBilling(state("past_due"), now), - "past_due keeps the plan, and no approved wording exists for that state") - }) - - t.Run("a subscription Stripe gave up on blocks", func(t *testing.T) { - for _, status := range []string{"unpaid", "canceled"} { - b := EvaluateBilling(state(status), now) - require.NotNil(t, b, status) - assert.Equal(t, BannerIDBillingRestricted, b.BannerID, status) - assert.Equal(t, SeverityError, b.Severity, status) - assert.Equal(t, SurfaceModal, b.Surface, status) - assert.False(t, b.Dismissible, status) - } - }) - - t.Run("the wording is localized like every other banner", func(t *testing.T) { - b := EvaluateBilling(BillingState{Status: "unpaid", Locale: "fr"}, now) - require.NotNil(t, b) - assert.Equal(t, "fr", b.Lang) - assert.NotEqual(t, textBillingRestricted, b.Text, "the message id must not reach the document") - }) - - t.Run("a blocking dialog with no call to action is made closable", func(t *testing.T) { - b := EvaluateBilling(state("unpaid"), now) - require.NotNil(t, b) - require.Nil(t, b.CTA, "no manager URL is configured in this state") - ensureEscapable(b) - assert.True(t, b.Dismissible, "otherwise the user cannot reach the application at all") - }) -} diff --git a/model/banner/billing.go b/model/banner/billing.go deleted file mode 100644 index ef1992b33f2..00000000000 --- a/model/banner/billing.go +++ /dev/null @@ -1,130 +0,0 @@ -package banner - -import ( - "time" - - "github.com/cozy/cozy-stack/model/instance" - "github.com/cozy/cozy-stack/model/instance/lifecycle" - "github.com/cozy/cozy-stack/pkg/config/config" - "github.com/cozy/cozy-stack/pkg/prefixer" -) - -// BannerIDBillingRestricted identifies the one payment state that has an -// approved design. -const BannerIDBillingRestricted = "billing.restricted" - -// TriggerPaymentFailed is recorded on documents produced by a payment event. -// There is no recovered counterpart: a recovery deletes the document. -const TriggerPaymentFailed = "payment.failed" - -// The wording, as message ids of the stack locales. -const ( - textBillingRestrictedTitle = "Banners Billing Restricted Title" - textBillingRestricted = "Banners Billing Restricted Text" - textBillingCTALabel = "Banners Billing CTA Label" - textBillingSupportLabel = "Banners Billing Support Label" -) - -// BillingState is what the billing rules need to decide. It is the payment -// event plus the instance wording context, kept separate from the instance so -// the rules stay testable without one. -type BillingState struct { - // Status is the subscription status as Stripe reports it, verbatim. - Status string - Locale string - // ContextName can override a translation. - ContextName string - // ManagerURL is where the call to action points, empty when unknown. - ManagerURL string -} - -// EvaluateBilling returns the banner that applies to a payment state, or nil -// when none does. -func EvaluateBilling(state BillingState, now time.Time) *Banner { - // Only a subscription Stripe has given up on produces a banner. While it is - // retrying the status is past_due, the Cloudery keeps the plan, and every - // approved wording describes an already restricted workspace, so there is - // nothing to say to a user whose access is intact. - if state.Status != "unpaid" && state.Status != "canceled" { - return nil - } - - startsAt := now - banner := &Banner{ - BannerID: BannerIDBillingRestricted, - Category: CategoryBilling, - Severity: SeverityError, - Surface: SurfaceModal, - Title: translate(state.Locale, state.ContextName, textBillingRestrictedTitle), - Text: translate(state.Locale, state.ContextName, textBillingRestricted), - Lang: lang(state.Locale), - Dismissible: false, - Priority: 200, - StartsAt: &startsAt, - Source: Source{Trigger: TriggerPaymentFailed, At: now}, - } - if target := ctaTarget(state.ManagerURL); target != "" { - banner.CTA = &CTA{Label: translate(state.Locale, state.ContextName, textBillingCTALabel), URL: target} - // cozy-client drops a secondary action that has no primary, so it - // only makes sense alongside one. - banner.SecondaryCTA = &CTA{ - Label: translate(state.Locale, state.ContextName, textBillingSupportLabel), - URL: "https://twake.app/support", - } - } - return banner -} - -// RefreshBilling re-evaluates the billing banner of an instance from a payment -// event. eventAt is the moment Stripe recorded the event, not the moment this -// runs, so it both stamps the document and orders it against what is stored. -func RefreshBilling(domain, status string, eventAt time.Time) error { - inst, err := lifecycle.GetInstance(domain) - if err != nil { - return err - } - if !inst.HasBannersEnabled() { - return nil - } - - mu := config.Lock().ReadWrite(inst, "banners") - if err := mu.Lock(); err != nil { - return err - } - defer mu.Unlock() - - stale, err := supersededBy(inst, CategoryBilling, eventAt) - if err != nil || stale { - return err - } - - state := BillingState{ - Status: status, - Locale: inst.Locale, - ContextName: inst.ContextName, - } - if premium, err := inst.ManagerURL(instance.ManagerPremiumURL); err == nil { - state.ManagerURL = premium - } - - return Materialize(inst, CategoryBilling, EvaluateBilling(state, eventAt), time.Now()) -} - -// supersededBy reports whether the stored banner was produced by an event at -// least as recent as this one, in which case this one is a redelivery or -// arrived out of order. Bus delivery is at-least-once and unordered, so -// without this a redelivered failure could overwrite a recovery. -// -// ponytail: Source.At is the event that last changed the document, not the -// last one seen, since an unchanged re-evaluation writes nothing. So a failure -// redelivered after a recovery deleted the document recreates it, and a stale -// recovery between two identical failures clears it. Both need a single queue -// reordered or a replay past the Cloudery's own dedupe; record the last -// applied event time per instance if that ever happens. -func supersededBy(db prefixer.Prefixer, category string, eventAt time.Time) (bool, error) { - stored, err := Stored(db, category) - if err != nil || stored == nil { - return false, err - } - return !stored.Source.At.Before(eventAt), nil -} diff --git a/model/banner/command.go b/model/banner/command.go new file mode 100644 index 00000000000..611194c8767 --- /dev/null +++ b/model/banner/command.go @@ -0,0 +1,404 @@ +package banner + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "time" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/prefixer" +) + +// TriggerCommand is recorded on documents a backend asked for rather than a +// rule the stack runs itself. +const TriggerCommand = "banner.command" + +// ErrInvalidCommand marks a command no retry can fix. A transport rejects such +// a delivery instead of requeuing it; anything else is a storage failure worth +// retrying. +var ErrInvalidCommand = errors.New("invalid banner command") + +// Localized is wording keyed by locale, as the backend sends it. +type Localized map[string]string + +// CommandCTA is a call to action before a locale has been picked for it. +type CommandCTA struct { + Label Localized `json:"label"` + URL string `json:"url"` +} + +// Command is what a backend asks for, independent of how it arrived. +type Command struct { + Category string `json:"category"` + + // Exactly one of Domain and WorkplaceFqdn is set. Domain addresses a + // B2B organization, and every instance under it gets the banner. + Domain string `json:"domain,omitempty"` + WorkplaceFqdn string `json:"workplaceFqdn,omitempty"` + + // EventID is the backend's correlation id, logged for traceability. + EventID string `json:"eventId,omitempty"` + // Revision is a positive counter the backend increments per target and + // category. Delivery is at-least-once and unordered, so this (not the + // arrival time) orders a command against what is stored. + Revision int64 `json:"revision"` + // Timestamp is when the backend decided, in epoch seconds. Provenance + // only: it orders nothing. + Timestamp int64 `json:"timestamp"` + + // Clear empties the category instead of materializing into it. Set by the + // transport (routing key), never read from the payload. + Clear bool `json:"-"` + + BannerID string `json:"bannerId,omitempty"` + Severity string `json:"severity,omitempty"` + Surface string `json:"surface,omitempty"` + Title Localized `json:"title,omitempty"` + Text Localized `json:"text,omitempty"` + CTA *CommandCTA `json:"cta,omitempty"` + SecondaryCTA *CommandCTA `json:"secondaryCta,omitempty"` + Dismissible bool `json:"dismissible,omitempty"` + Priority int `json:"priority,omitempty"` + StartsAt *time.Time `json:"startsAt,omitempty"` + EndsAt *time.Time `json:"endsAt,omitempty"` +} + +// The boundary rejects rather than repairs: anything unexpected is a backend bug. +var ( + categoryFormat = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) + bannerIDFormat = regexp.MustCompile(`^[a-z0-9.-]{1,64}$`) + targetFormat = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$`) +) + +const ( + maxLabelLen = 128 + maxTitleLen = 256 + maxTextLen = 1024 + maxURLLen = 2048 + maxPriority = 1000 + maxEventIDLen = 256 + maxLocaleLen = 35 + // MaxCommandBytes bounds the JSON body at the transport boundary. + MaxCommandBytes = 256 * 1024 + // maxLocales bounds the locale map, not each value. + maxLocales = 32 +) + +// ApplyCommand materializes or clears the banner a backend asked for. +func ApplyCommand(cmd Command) error { + if err := cmd.validate(); err != nil { + return err + } + + instances, err := cmd.targets() + if err != nil { + return err + } + for _, inst := range instances { + if inst.HasBannersEnabled() && !inst.AllowsBannerCategory(cmd.Category) { + return fmt.Errorf("%w: the context of %s does not accept commands for the %s category", + ErrInvalidCommand, inst.Domain, cmd.Category) + } + } + for _, inst := range instances { + if err := cmd.applyTo(inst); err != nil { + return fmt.Errorf("%s: %w", inst.Domain, err) + } + } + return nil +} + +// targets resolves what the backend addressed. An organization with no +// instance is a no-op. A missing workplace is retryable (not invalid): the +// stack cannot tell a deleted instance from one still being provisioned. +func (cmd Command) targets() ([]*instance.Instance, error) { + if cmd.Domain != "" { + list, err := lifecycle.ListOrgInstances(cmd.Domain) + if err != nil { + return nil, fmt.Errorf("cannot list the instances of organization %s: %w", cmd.Domain, err) + } + return list, nil + } + inst, err := lifecycle.GetInstance(cmd.WorkplaceFqdn) + if err != nil { + return nil, err + } + return []*instance.Instance{inst}, nil +} + +func (cmd Command) applyTo(inst *instance.Instance) error { + // An instance that displays no banner is a no-op, not a rejection. + if !inst.HasBannersEnabled() { + return nil + } + + // The lock makes the read-then-write of the stored revision atomic across + // concurrent deliveries and stack processes. + mu := config.Lock().ReadWrite(inst, "banners") + if err := mu.Lock(); err != nil { + return err + } + defer mu.Unlock() + + stored, err := storedCommand(inst, cmd.Category) + if err != nil { + return err + } + if stored != nil && cmd.Revision <= stored.Revision { + log(inst).Infof("%s: ignoring revision %d, not newer than the stored %d", + cmd.Category, cmd.Revision, stored.Revision) + return nil + } + + // Banner before record: a crash in between is healed by the next + // delivery (Materialize is idempotent). The reverse would record a + // decision the user never saw. + if err := Materialize(inst, cmd.Category, cmd.banner(inst.Locale), time.Now()); err != nil { + return err + } + + next := cmd.state() + if stored != nil { + next.DocID, next.DocRev = stored.DocID, stored.DocRev + return couchdb.UpdateDoc(inst, next) + } + next.DocID = docID(cmd.Category) + return couchdb.CreateNamedDocWithDB(inst, next) +} + +// commandState records the last command accepted for a category, so ordering +// survives clears (which leave no public document) and unchanged decisions. +type commandState struct { + DocID string `json:"_id,omitempty"` + DocRev string `json:"_rev,omitempty"` + + Category string `json:"category"` + Revision int64 `json:"revision"` + Clear bool `json:"clear"` + EventID string `json:"eventId,omitempty"` +} + +func (d *commandState) ID() string { return d.DocID } +func (d *commandState) Rev() string { return d.DocRev } +func (d *commandState) DocType() string { return consts.BannerCommands } +func (d *commandState) SetID(id string) { d.DocID = id } +func (d *commandState) SetRev(rev string) { d.DocRev = rev } +func (d *commandState) Clone() couchdb.Doc { cloned := *d; return &cloned } + +var _ couchdb.Doc = &commandState{} + +func (cmd Command) state() *commandState { + return &commandState{ + Category: cmd.Category, + Revision: cmd.Revision, + Clear: cmd.Clear, + EventID: cmd.EventID, + } +} + +// storedCommand returns the state retained for a category, or nil when none +// exists (including a missing database, which is the first command on an instance). +func storedCommand(db prefixer.Prefixer, category string) (*commandState, error) { + var doc commandState + err := couchdb.GetDoc(db, consts.BannerCommands, docID(category), &doc) + if couchdb.IsNotFoundError(err) || couchdb.IsNoDatabaseError(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &doc, nil +} + +// banner is the document the command asks for, or nil for a clear. +func (cmd Command) banner(instanceLocale string) *Banner { + if cmd.Clear { + return nil + } + at := time.Unix(cmd.Timestamp, 0).UTC() + locale := cmd.locale(instanceLocale) + b := &Banner{ + BannerID: cmd.BannerID, + Category: cmd.Category, + Severity: cmd.Severity, + Surface: cmd.Surface, + Title: cmd.Title[locale], + Text: cmd.Text[locale], + Lang: locale, + CTA: cmd.CTA.pick(locale), + SecondaryCTA: cmd.SecondaryCTA.pick(locale), + Dismissible: cmd.Dismissible, + Priority: cmd.Priority, + StartsAt: cmd.StartsAt, + EndsAt: cmd.EndsAt, + Source: Source{Trigger: TriggerCommand, At: at}, + } + // Default to the decision time so a redelivery is identical to the original. + if b.StartsAt == nil { + startsAt := at + b.StartsAt = &startsAt + } + return b +} + +// locale picks one language for the whole banner. A locale the backend only +// half sent is not used, to avoid mixing languages within a single banner. +func (cmd Command) locale(instanceLocale string) string { + if asked := lang(instanceLocale); cmd.complete(asked) { + return asked + } + return consts.DefaultLocale +} + +// complete reports whether every string the document will carry exists in +// the given locale. +func (cmd Command) complete(locale string) bool { + if cmd.Text[locale] == "" { + return false + } + if len(cmd.Title) > 0 && cmd.Title[locale] == "" { + return false + } + return cmd.CTA.labelled(locale) && cmd.SecondaryCTA.labelled(locale) +} + +func (c *CommandCTA) labelled(locale string) bool { + return c == nil || c.Label[locale] != "" +} + +func (c *CommandCTA) pick(locale string) *CTA { + if c == nil { + return nil + } + return &CTA{Label: c.Label[locale], URL: c.URL} +} + +func (cmd Command) validate() error { + if !categoryFormat.MatchString(cmd.Category) { + return fmt.Errorf("%w: category %q is not a valid category", ErrInvalidCommand, cmd.Category) + } + // The quota category is measured by the stack itself. + if cmd.Category == CategoryQuota { + return fmt.Errorf("%w: the %s category is reserved for the stack's own rules", + ErrInvalidCommand, CategoryQuota) + } + if (cmd.Domain == "") == (cmd.WorkplaceFqdn == "") { + return fmt.Errorf("%w: exactly one of domain and workplaceFqdn is required", ErrInvalidCommand) + } + if target := cmd.Domain + cmd.WorkplaceFqdn; !targetFormat.MatchString(target) { + return fmt.Errorf("%w: %q is not a valid target", ErrInvalidCommand, target) + } + if cmd.Revision <= 0 { + return fmt.Errorf("%w: a positive revision is required", ErrInvalidCommand) + } + if cmd.Timestamp <= 0 { + return fmt.Errorf("%w: timestamp is required", ErrInvalidCommand) + } + if _, err := time.Unix(cmd.Timestamp, 0).UTC().MarshalJSON(); err != nil { + return fmt.Errorf("%w: timestamp must be epoch seconds within the RFC3339 range", ErrInvalidCommand) + } + if len(cmd.EventID) > maxEventIDLen { + return fmt.Errorf("%w: eventId is longer than %d bytes", ErrInvalidCommand, maxEventIDLen) + } + // A clear carries no presentation fields. + if cmd.Clear { + if cmd.BannerID != "" || cmd.Severity != "" || cmd.Surface != "" || + len(cmd.Title) != 0 || len(cmd.Text) != 0 || cmd.CTA != nil || cmd.SecondaryCTA != nil || + cmd.Dismissible || cmd.Priority != 0 || cmd.StartsAt != nil || cmd.EndsAt != nil { + return fmt.Errorf("%w: clear must not carry presentation fields", ErrInvalidCommand) + } + return nil + } + + if !bannerIDFormat.MatchString(cmd.BannerID) { + return fmt.Errorf("%w: bannerId %q is not a valid identifier", ErrInvalidCommand, cmd.BannerID) + } + switch cmd.Severity { + case SeverityInfo, SeverityWarning, SeverityError: + default: + return fmt.Errorf("%w: severity %q is not one of info, warning, error", ErrInvalidCommand, cmd.Severity) + } + switch cmd.Surface { + case SurfaceBanner, SurfaceModal: + default: + return fmt.Errorf("%w: surface %q is not one of banner, modal", ErrInvalidCommand, cmd.Surface) + } + if cmd.Priority < 0 || cmd.Priority > maxPriority { + return fmt.Errorf("%w: priority %d is outside 0..%d", ErrInvalidCommand, cmd.Priority, maxPriority) + } + startsAt := time.Unix(cmd.Timestamp, 0).UTC() + if cmd.StartsAt != nil { + startsAt = *cmd.StartsAt + } + for _, at := range []*time.Time{cmd.StartsAt, cmd.EndsAt} { + if at != nil { + if _, err := at.MarshalJSON(); err != nil { + return fmt.Errorf("%w: window must be within the RFC3339 range", ErrInvalidCommand) + } + } + } + if cmd.EndsAt != nil && !startsAt.Before(*cmd.EndsAt) { + return fmt.Errorf("%w: startsAt is not before endsAt", ErrInvalidCommand) + } + // cozy-client drops a secondary action that has no primary. + if cmd.SecondaryCTA != nil && cmd.CTA == nil { + return fmt.Errorf("%w: secondaryCta needs a cta", ErrInvalidCommand) + } + if err := cmd.Text.validate("text", maxTextLen); err != nil { + return err + } + if err := cmd.Title.validate("title", maxTitleLen); err != nil { + return err + } + if err := cmd.CTA.validate("cta"); err != nil { + return err + } + if err := cmd.SecondaryCTA.validate("secondaryCta"); err != nil { + return err + } + if !cmd.complete(consts.DefaultLocale) { + return fmt.Errorf("%w: every text and label is required in the %s fallback locale", + ErrInvalidCommand, consts.DefaultLocale) + } + raw, err := json.Marshal(cmd) + if err != nil || len(raw) > MaxCommandBytes { + return fmt.Errorf("%w: command must encode to at most %d JSON bytes", ErrInvalidCommand, MaxCommandBytes) + } + return nil +} + +// validate checks every locale, not just the one that will be picked. +func (l Localized) validate(field string, max int) error { + if len(l) > maxLocales { + return fmt.Errorf("%w: %s carries more than %d locales", ErrInvalidCommand, field, maxLocales) + } + for locale, text := range l { + if len(locale) == 0 || len(locale) > maxLocaleLen { + return fmt.Errorf("%w: %s locale key must be 1..%d bytes", ErrInvalidCommand, field, maxLocaleLen) + } + if len(text) > max { + return fmt.Errorf("%w: %s is longer than %d bytes in locale %s", + ErrInvalidCommand, field, max, locale) + } + } + return nil +} + +func (c *CommandCTA) validate(field string) error { + if c == nil { + return nil + } + if len(c.URL) > maxURLLen { + return fmt.Errorf("%w: %s.url is longer than %d bytes", ErrInvalidCommand, field, maxURLLen) + } + if ctaTarget(c.URL) == "" { + return fmt.Errorf("%w: %s.url is not an absolute https URL", ErrInvalidCommand, field) + } + return c.Label.validate(field+".label", maxLabelLen) +} diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go new file mode 100644 index 00000000000..ed96fbd9cd8 --- /dev/null +++ b/model/banner/command_io_test.go @@ -0,0 +1,94 @@ +package banner + +import ( + "errors" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/prefixer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type commandRoundTripper func(*http.Request) (*http.Response, error) + +func (f commandRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + useCommandContexts(t) + first := newInstance(t, commandContext, "en", "") + org := first.Domain + first.OrgDomain = org + // The instance helper registers cleanup; creation of the other members + // uses the same org without assuming CouchDB's member ordering. + require.NoError(t, couchdb.UpdateDoc(prefixer.GlobalPrefixer, first)) + newInstance(t, commandContext, "fr", org) + newInstance(t, commandContext, "en", org) + members, err := lifecycle.ListOrgInstances(org) + require.NoError(t, err) + require.Len(t, members, 3) + cmd := fixture("organization") + cmd.Domain = org + failPath := "/" + couchdb.EscapeCouchdbName(members[1].DBPrefix()+"/"+consts.Banners) + "/banner-billing" + client := config.CouchClient() + original := client.Transport + t.Cleanup(func() { client.Transport = original }) + var failed atomic.Bool + client.Transport = commandRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == failPath && failed.CompareAndSwap(false, true) { + return nil, errors.New("simulated projection outage") + } + return original.RoundTrip(r) + }) + err = ApplyCommand(cmd) + require.ErrorContains(t, err, "simulated projection outage") + assert.NotErrorIs(t, err, ErrInvalidCommand) + before := storedBanner(t, members[0]) + require.NotNil(t, before) + assert.Nil(t, storedBanner(t, members[1])) + assert.Nil(t, storedBanner(t, members[2])) + retained, err := storedCommand(members[1], CategoryBilling) + require.NoError(t, err) + require.Nil(t, retained, "nothing is recorded for a member whose banner was not written") + require.NoError(t, ApplyCommand(cmd)) + for _, inst := range members { + require.NotNil(t, storedBanner(t, inst)) + } + assert.Equal(t, before.DocRev, storedBanner(t, members[0]).DocRev) +} + +func TestCommandClearRetriesProjectionFailure(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + useCommandContexts(t) + inst := newInstance(t, commandContext, "en", "") + old := materialize(t, inst, 1) + require.NoError(t, ApplyCommand(old)) + client := config.CouchClient() + original := client.Transport + t.Cleanup(func() { client.Transport = original }) + var failed atomic.Bool + client.Transport = commandRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/banner-billing") && failed.CompareAndSwap(false, true) { + return nil, errors.New("simulated delete outage") + } + return original.RoundTrip(r) + }) + clear := clearCommand(t, inst, 2) + require.ErrorContains(t, ApplyCommand(clear), "simulated delete outage") + retained, err := storedCommand(inst, CategoryBilling) + require.NoError(t, err) + require.False(t, retained.Clear, "the clear is only recorded once the document is gone") + require.Equal(t, int64(1), retained.Revision) + require.NoError(t, ApplyCommand(old)) + require.NoError(t, ApplyCommand(clear)) + assert.Nil(t, storedBanner(t, inst)) +} diff --git a/model/banner/command_test.go b/model/banner/command_test.go new file mode 100644 index 00000000000..246ec9ff6af --- /dev/null +++ b/model/banner/command_test.go @@ -0,0 +1,667 @@ +package banner + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const decidedAt = 1788944400 + +// fixture decodes one of the shared wire fixtures, which are what the backend +// publisher is developed against. A field this package stops reading, or a +// field it starts requiring, breaks here rather than in production. +func fixture(name string) Command { + raw, err := os.ReadFile("testdata/" + name + ".json") + if err != nil { + panic(err) + } + var cmd Command + if err := json.Unmarshal(raw, &cmd); err != nil { + panic(err) + } + return cmd +} + +// valid is a command every field of which passes, so a case can break exactly +// one thing and name what it broke. +func valid() Command { return fixture("materialize") } + +func TestFixturesAreTheContract(t *testing.T) { + t.Run("a materialize carries its decision and every locale of it", func(t *testing.T) { + cmd := fixture("materialize") + + assert.Equal(t, "alice.twake.app", cmd.WorkplaceFqdn) + assert.Empty(t, cmd.Domain) + assert.Equal(t, "banner-command-42", cmd.EventID) + assert.Equal(t, int64(42), cmd.Revision) + assert.Equal(t, int64(decidedAt), cmd.Timestamp) + assert.Equal(t, CategoryBilling, cmd.Category) + assert.Equal(t, "billing.grace.cycle-a.attempt-2", cmd.BannerID) + assert.Equal(t, SeverityWarning, cmd.Severity) + assert.Equal(t, SurfaceBanner, cmd.Surface) + assert.Equal(t, 150, cmd.Priority) + assert.True(t, cmd.Dismissible) + assert.Equal(t, "Échec du paiement", cmd.Title["fr"]) + require.NotNil(t, cmd.CTA) + assert.Equal(t, "Mettre à jour le moyen de paiement", cmd.CTA.Label["fr"]) + require.NotNil(t, cmd.SecondaryCTA) + require.NotNil(t, cmd.StartsAt) + require.NotNil(t, cmd.EndsAt) + assert.NoError(t, cmd.validate()) + }) + + t.Run("a clear carries no wording", func(t *testing.T) { + cmd := fixture("clear") + cmd.Clear = true + + assert.Empty(t, cmd.BannerID) + assert.Empty(t, cmd.Text) + assert.Equal(t, int64(43), cmd.Revision) + assert.NoError(t, cmd.validate()) + }) + + t.Run("an organization is addressed by its domain", func(t *testing.T) { + cmd := fixture("organization") + + assert.Equal(t, "acme.example", cmd.Domain) + assert.Empty(t, cmd.WorkplaceFqdn) + assert.Equal(t, SurfaceModal, cmd.Surface) + assert.NoError(t, cmd.validate()) + }) + + t.Run("the payload cannot smuggle document fields", func(t *testing.T) { + raw := []byte(`{ + "category": "billing", "workplaceFqdn": "alice.twake.app", + "revision": 1, "timestamp": 1788944400, + "bannerId": "billing.restricted", "severity": "error", "surface": "banner", + "text": {"en": "restricted"}, + "_id": "banner-billing", "_rev": "9-forged", "clear": true, + "dismissedAt": "2026-01-01T00:00:00Z", + "cozyMetadata": {"createdByApp": "drive"} + }`) + var cmd Command + require.NoError(t, json.Unmarshal(raw, &cmd)) + + assert.False(t, cmd.Clear, "only the routing key decides a clear") + b := cmd.banner("en") + require.NotNil(t, b) + assert.Empty(t, b.DocID) + assert.Empty(t, b.DocRev) + assert.Nil(t, b.DismissedAt) + assert.Nil(t, b.Metadata) + }) +} + +// TestValidateRejections has one case per rejection: a missing case is a +// hole, not a gap in coverage. +func TestValidateRejections(t *testing.T) { + cases := []struct { + name string + break_ func(*Command) + want string + }{ + {"no category", func(c *Command) { c.Category = "" }, "not a valid category"}, + {"category with an upper case letter", func(c *Command) { c.Category = "Billing" }, "not a valid category"}, + {"category starting with a digit", func(c *Command) { c.Category = "2fa" }, "not a valid category"}, + {"category too long", func(c *Command) { c.Category = strings.Repeat("a", 33) }, "not a valid category"}, + {"the quota category", func(c *Command) { c.Category = CategoryQuota }, "reserved for the stack's own rules"}, + {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of domain and workplaceFqdn"}, + {"both targets", func(c *Command) { c.Domain = "acme.example" }, "exactly one of domain and workplaceFqdn"}, + {"a target with a path", func(c *Command) { c.WorkplaceFqdn = "alice.twake.app/../bob" }, "is not a valid target"}, + {"a target with a scheme", func(c *Command) { c.WorkplaceFqdn = "https://alice.twake.app" }, "is not a valid target"}, + {"a target too long", func(c *Command) { c.WorkplaceFqdn = strings.Repeat("a", 256) }, "is not a valid target"}, + {"no revision", func(c *Command) { c.Revision = 0 }, "a positive revision is required"}, + {"a negative revision", func(c *Command) { c.Revision = -1 }, "a positive revision is required"}, + {"no timestamp", func(c *Command) { c.Timestamp = 0 }, "timestamp is required"}, + {"timestamp in milliseconds", func(c *Command) { c.Timestamp *= 1000 }, "timestamp must be epoch seconds"}, + {"event id too long", func(c *Command) { c.EventID = strings.Repeat("e", maxEventIDLen+1) }, "eventId is longer than"}, + {"no bannerId", func(c *Command) { c.BannerID = "" }, "not a valid identifier"}, + {"bannerId with a slash", func(c *Command) { c.BannerID = "billing/grace" }, "not a valid identifier"}, + {"bannerId too long", func(c *Command) { c.BannerID = strings.Repeat("a", 65) }, "not a valid identifier"}, + {"unknown severity", func(c *Command) { c.Severity = "critical" }, "severity"}, + {"no severity", func(c *Command) { c.Severity = "" }, "severity"}, + {"unknown surface", func(c *Command) { c.Surface = "toast" }, "surface"}, + {"no surface", func(c *Command) { c.Surface = "" }, "surface"}, + {"a negative priority", func(c *Command) { c.Priority = -1 }, "priority"}, + {"a priority above the range", func(c *Command) { c.Priority = maxPriority + 1 }, "priority"}, + {"a window that ends before it starts", func(c *Command) { c.StartsAt, c.EndsAt = c.EndsAt, c.StartsAt }, "startsAt is not before endsAt"}, + {"a window with no length", func(c *Command) { c.EndsAt = c.StartsAt }, "startsAt is not before endsAt"}, + {"implicit start after end", func(c *Command) { c.StartsAt = nil }, "startsAt is not before endsAt"}, + {"implicit start equals end", func(c *Command) { c.StartsAt = nil; at := time.Unix(c.Timestamp, 0); c.EndsAt = &at }, "startsAt is not before endsAt"}, + {"window outside RFC3339", func(c *Command) { at := time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC); c.EndsAt = &at }, "window must be within"}, + {"no text", func(c *Command) { c.Text = nil }, "required in the en fallback locale"}, + {"text without the fallback locale", func(c *Command) { delete(c.Text, "en") }, "required in the en fallback locale"}, + {"a title without the fallback locale", func(c *Command) { delete(c.Title, "en") }, "required in the en fallback locale"}, + {"a cta label without the fallback locale", func(c *Command) { delete(c.CTA.Label, "en") }, "required in the en fallback locale"}, + {"text too long", func(c *Command) { c.Text["en"] = strings.Repeat("a", maxTextLen+1) }, "text is longer than"}, + {"locale key too long", func(c *Command) { c.Text[strings.Repeat("a", maxLocaleLen+1)] = "text" }, "locale key must be"}, + {"empty locale key", func(c *Command) { c.Text[""] = "text" }, "locale key must be"}, + {"more locales than a document could ever read", func(c *Command) { + for i := 0; i <= maxLocales; i++ { + c.Text[fmt.Sprintf("l%d", i)] = "filler" + } + }, "carries more than"}, + {"text too long in another locale", func(c *Command) { c.Text["fr"] = strings.Repeat("a", maxTextLen+1) }, "text is longer than"}, + {"title too long", func(c *Command) { c.Title["en"] = strings.Repeat("a", maxTitleLen+1) }, "title is longer than"}, + {"cta over http", func(c *Command) { c.CTA.URL = "http://manager.example.org" }, "cta.url is not an absolute https URL"}, + {"cta with a javascript scheme", func(c *Command) { c.CTA.URL = "javascript:alert(1)" }, "cta.url is not an absolute https URL"}, + {"cta with a relative url", func(c *Command) { c.CTA.URL = "/billing" }, "cta.url is not an absolute https URL"}, + {"cta with no url", func(c *Command) { c.CTA.URL = "" }, "cta.url is not an absolute https URL"}, + {"cta url too long", func(c *Command) { c.CTA.URL = "https://manager.example.org/" + strings.Repeat("a", maxURLLen) }, "cta.url is longer than"}, + {"cta label too long", func(c *Command) { c.CTA.Label["en"] = strings.Repeat("a", maxLabelLen+1) }, "cta.label is longer than"}, + {"secondary cta over http", func(c *Command) { c.SecondaryCTA.URL = "http://twake.app/support" }, "secondaryCta.url is not an absolute https URL"}, + {"a secondary action with no primary", func(c *Command) { c.CTA = nil }, "secondaryCta needs a cta"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := valid() + tc.break_(&cmd) + err := cmd.validate() + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidCommand, "a transport has to know not to retry this") + assert.Contains(t, err.Error(), tc.want) + }) + } + + t.Run("a complete command is accepted", func(t *testing.T) { + assert.NoError(t, valid().validate()) + }) + + t.Run("a clear is still addressed and ordered", func(t *testing.T) { + clear := func() Command { + cmd := fixture("clear") + cmd.Clear = true + return cmd + } + for _, tc := range []struct { + name string + break_ func(*Command) + want string + }{ + {"no revision", func(c *Command) { c.Revision = 0 }, "a positive revision is required"}, + {"no timestamp", func(c *Command) { c.Timestamp = 0 }, "timestamp is required"}, + {"timestamp in milliseconds", func(c *Command) { c.Timestamp *= 1000 }, "timestamp must be epoch seconds"}, + {"oversized event id", func(c *Command) { c.EventID = strings.Repeat("e", maxEventIDLen+1) }, "eventId is longer than"}, + {"oversized wording", func(c *Command) { c.Text = Localized{"en": strings.Repeat("x", 2<<20)} }, "clear must not carry presentation"}, + {"ordinary wording", func(c *Command) { c.Text = Localized{"en": "ignored?"} }, "clear must not carry presentation"}, + {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of domain and workplaceFqdn"}, + {"the quota category", func(c *Command) { c.Category = CategoryQuota }, "reserved"}, + } { + cmd := clear() + tc.break_(&cmd) + assert.ErrorContains(t, cmd.validate(), tc.want, tc.name) + } + }) +} + +func TestCommandDocumentShape(t *testing.T) { + at := time.Unix(decidedAt, 0).UTC() + + t.Run("every field of the contract", func(t *testing.T) { + b := valid().banner("en") + require.NotNil(t, b) + assert.Equal(t, "billing.grace.cycle-a.attempt-2", b.BannerID) + assert.Equal(t, CategoryBilling, b.Category) + assert.Equal(t, SeverityWarning, b.Severity) + assert.Equal(t, SurfaceBanner, b.Surface) + assert.True(t, b.Dismissible) + assert.Equal(t, 150, b.Priority) + assert.Equal(t, "Payment failed", b.Title) + require.NotNil(t, b.SecondaryCTA) + assert.Equal(t, "https://twake.app/support", b.SecondaryCTA.URL) + assert.Equal(t, TriggerCommand, b.Source.Trigger) + assert.Equal(t, at, b.Source.At, "the decision time is provenance on the document") + require.NotNil(t, b.StartsAt) + require.NotNil(t, b.EndsAt) + }) + + t.Run("a window the backend left out starts when it decided", func(t *testing.T) { + cmd := valid() + cmd.StartsAt, cmd.EndsAt = nil, nil + b := cmd.banner("en") + require.NotNil(t, b) + require.NotNil(t, b.StartsAt) + assert.Equal(t, at, *b.StartsAt, "so a redelivery is identical to the original") + assert.Nil(t, b.EndsAt) + }) + + t.Run("a clear produces no document", func(t *testing.T) { + cmd := valid() + cmd.Clear = true + assert.Nil(t, cmd.banner("en")) + }) +} + +func TestLocaleIsPickedForTheWholeBanner(t *testing.T) { + t.Run("the instance locale when the backend sent all of it", func(t *testing.T) { + b := valid().banner("fr") + require.NotNil(t, b) + assert.Equal(t, "Échec du paiement", b.Title) + assert.Contains(t, b.Text, "Nous n'avons pas pu") + assert.Equal(t, "Mettre à jour le moyen de paiement", b.CTA.Label) + assert.Equal(t, "fr", b.Lang) + }) + + t.Run("the fallback locale when the backend sent none of it", func(t *testing.T) { + b := valid().banner("de") + require.NotNil(t, b) + assert.Equal(t, "Payment failed", b.Title) + assert.Equal(t, "en", b.Lang, "lang names the language the user actually reads") + }) + + t.Run("an instance with no locale reads the fallback", func(t *testing.T) { + b := valid().banner("") + require.NotNil(t, b) + assert.Equal(t, "en", b.Lang) + }) + + t.Run("a language the stack has no catalog for is still the backend's to send", func(t *testing.T) { + require.NotContains(t, consts.SupportedLocales, "ru") + cmd := valid() + cmd.Text["ru"] = "Мы не смогли списать средства с вашей карты." + cmd.Title["ru"] = "Платёж не прошёл" + cmd.CTA.Label["ru"] = "Обновить способ оплаты" + cmd.SecondaryCTA.Label["ru"] = "Связаться со службой поддержки" + + b := cmd.banner("ru") + require.NotNil(t, b) + assert.Equal(t, "ru", b.Lang, "the stack renders none of this, so its catalogs have no say") + assert.Equal(t, "Платёж не прошёл", b.Title) + }) + + t.Run("a half translated locale is not used at all", func(t *testing.T) { + for _, missing := range []func(*Command){ + func(c *Command) { delete(c.Text, "fr") }, + func(c *Command) { delete(c.Title, "fr") }, + func(c *Command) { delete(c.CTA.Label, "fr") }, + func(c *Command) { delete(c.SecondaryCTA.Label, "fr") }, + } { + cmd := valid() + missing(&cmd) + b := cmd.banner("fr") + require.NotNil(t, b) + assert.Equal(t, "en", b.Lang, "a sentence and its button must be in one language") + assert.Equal(t, "Payment failed", b.Title) + } + }) +} + +// needCouchDB is testutils.NeedCouchdb, inlined to avoid a circular import. +func needCouchDB(t *testing.T) { + t.Helper() + if _, err := couchdb.CheckStatus(context.Background()); err != nil { + t.Fatal("This test need couchdb to run.") + } +} + +const ( + commandContext = "banner-command-test" + refusedContext = "banner-command-test-refused" + noBannerContext = "banner-command-test-off" +) + +// useCommandContexts registers the test contexts used by the command tests. +func useCommandContexts(t *testing.T) { + t.Helper() + conf := config.GetConfig() + previous := conf.Contexts + conf.Contexts = map[string]interface{}{ + commandContext: map[string]interface{}{ + "enable_banners": true, + "banner_command_categories": []interface{}{CategoryBilling, CategoryTrial}, + }, + refusedContext: map[string]interface{}{ + "enable_banners": true, + }, + noBannerContext: map[string]interface{}{}, + } + t.Cleanup(func() { conf.Contexts = previous }) +} + +func newInstance(t *testing.T, contextName, locale, orgDomain string) *instance.Instance { + t.Helper() + domain := fmt.Sprintf("banner-cmd-%d.example", time.Now().UnixNano()) + inst, err := lifecycle.Create(&lifecycle.Options{ + Domain: domain, + Email: "alice@example.org", + Locale: locale, + ContextName: contextName, + OrgDomain: orgDomain, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) + return inst +} + +// materialize returns the shared fixture aimed at one instance. Every command +// keeps the fixture's timestamp, so only the revision orders them. +func materialize(t *testing.T, inst *instance.Instance, revision int64) Command { + t.Helper() + cmd := fixture("materialize") + cmd.WorkplaceFqdn = inst.Domain + cmd.Revision = revision + return cmd +} + +func clearCommand(t *testing.T, inst *instance.Instance, revision int64) Command { + t.Helper() + cmd := fixture("clear") + cmd.WorkplaceFqdn = inst.Domain + cmd.Revision = revision + cmd.Clear = true + return cmd +} + +func storedBanner(t *testing.T, inst *instance.Instance) *Banner { + t.Helper() + stored, err := Stored(inst, CategoryBilling) + require.NoError(t, err) + return stored +} + +func TestApplyCommand(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + useCommandContexts(t) + + t.Run("a materialize creates the document a client reads", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + + require.NoError(t, ApplyCommand(materialize(t, inst, 42))) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "banner-billing", stored.DocID, "one document per category") + assert.Equal(t, "billing.grace.cycle-a.attempt-2", stored.BannerID) + assert.Equal(t, stackAuthor, stored.Metadata.CreatedByApp, "clients gate trust on this") + assert.Equal(t, DocTypeVersion, stored.Metadata.DocTypeVersion) + assert.Equal(t, TriggerCommand, stored.Source.Trigger) + }) + + t.Run("an unchanged newer decision advances the ordering without rewriting", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 10))) + created := storedBanner(t, inst) + require.NotNil(t, created) + + require.NoError(t, ApplyCommand(materialize(t, inst, 11))) + again := storedBanner(t, inst) + require.NotNil(t, again) + assert.Equal(t, created.DocRev, again.DocRev, "an unchanged banner must not wake the realtime clients") + + // The stale clear is what the old timestamp guard let through: the + // document it would compare against never moved. + require.NoError(t, ApplyCommand(clearCommand(t, inst, 10))) + assert.NotNil(t, storedBanner(t, inst), "a clear older than the last decision changes nothing") + }) + + t.Run("a clear removes the document and outlives a stale materialize", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 20))) + require.NotNil(t, storedBanner(t, inst)) + + require.NoError(t, ApplyCommand(clearCommand(t, inst, 21))) + assert.Nil(t, storedBanner(t, inst)) + + require.NoError(t, ApplyCommand(materialize(t, inst, 20))) + assert.Nil(t, storedBanner(t, inst), "the cleared category keeps its revision with no document to hold it") + }) + + t.Run("a redelivery of the same revision changes nothing", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 30))) + first := storedBanner(t, inst) + require.NotNil(t, first) + + require.NoError(t, ApplyCommand(materialize(t, inst, 30))) + again := storedBanner(t, inst) + require.NotNil(t, again) + assert.Equal(t, first.DocRev, again.DocRev) + }) + + // A revision reused with different wording is a backend bug the stack + // cannot repair, so it is ignored like any other non-newer revision rather + // than given a rejection path of its own. + t.Run("a revision reused for another payload is ignored", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 40))) + + other := materialize(t, inst, 40) + other.BannerID = "billing.restricted" + require.NoError(t, ApplyCommand(other)) + assert.Equal(t, "billing.grace.cycle-a.attempt-2", storedBanner(t, inst).BannerID) + }) + + t.Run("the same occurrence keeps a dismissal the user recorded", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 50))) + + dismissed := storedBanner(t, inst) + require.NotNil(t, dismissed) + at := time.Now().UTC().Truncate(time.Second) + dismissed.DismissedAt = &at + require.NoError(t, couchdb.UpdateDoc(inst, dismissed)) + + // A redelivery of the same revision, then a newer command with new + // wording for the same occurrence. + require.NoError(t, ApplyCommand(materialize(t, inst, 50))) + require.NotNil(t, storedBanner(t, inst).DismissedAt, "a retry must not resurrect a closed banner") + + reworded := materialize(t, inst, 51) + reworded.Text["en"] = "We could not charge your card. This is the last attempt." + require.NoError(t, ApplyCommand(reworded)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Contains(t, stored.Text, "last attempt") + require.NotNil(t, stored.DismissedAt, "same occurrence, same dismissal") + + // A new occurrence is a message the user has not seen. + escalated := materialize(t, inst, 52) + escalated.BannerID = "billing.grace.cycle-a.attempt-3" + require.NoError(t, ApplyCommand(escalated)) + assert.Nil(t, storedBanner(t, inst).DismissedAt) + }) + + t.Run("the instance locale decides the wording", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 60))) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "fr", stored.Lang) + assert.Equal(t, "Échec du paiement", stored.Title) + + // The stack keeps no copy of the other locales, so a language change + // leaves the wording as it was until the backend sends the next + // command. Only the stack's own rules reword themselves. + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "de"})) + stored = storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "fr", stored.Lang) + }) + + t.Run("a category the context does not accept is refused", func(t *testing.T) { + inst := newInstance(t, refusedContext, "en", "") + + err := ApplyCommand(materialize(t, inst, 80)) + assert.ErrorIs(t, err, ErrInvalidCommand) + assert.ErrorContains(t, err, "does not accept commands for the billing category") + assert.Nil(t, storedBanner(t, inst)) + }) + + t.Run("an instance that displays no banner is a no-op", func(t *testing.T) { + inst := newInstance(t, noBannerContext, "en", "") + + require.NoError(t, ApplyCommand(materialize(t, inst, 90))) + assert.Nil(t, storedBanner(t, inst)) + }) + + t.Run("an unknown workplace is retried, not rejected", func(t *testing.T) { + cmd := valid() + cmd.WorkplaceFqdn = fmt.Sprintf("missing-%d.example", time.Now().UnixNano()) + + err := ApplyCommand(cmd) + require.Error(t, err) + assert.NotErrorIs(t, err, ErrInvalidCommand, + "a workplace still being provisioned is indistinguishable from a deleted one") + }) + + t.Run("an invalid command never reaches an instance", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + cmd := materialize(t, inst, 100) + cmd.Severity = "critical" + + assert.ErrorIs(t, ApplyCommand(cmd), ErrInvalidCommand) + assert.Nil(t, storedBanner(t, inst)) + }) + + t.Run("a blocking modal with no way out is made closable", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + cmd := materialize(t, inst, 105) + cmd.Surface = SurfaceModal + cmd.Dismissible = false + cmd.CTA, cmd.SecondaryCTA = nil, nil + require.NoError(t, ApplyCommand(cmd)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.True(t, stored.Dismissible, "a reload is not a way out, it brings the same banner back") + }) + + t.Run("a commanded banner and a quota banner coexist", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + + quota := EvaluateQuota(QuotaState{Used: 10 * gigabyte, Quota: 10 * gigabyte}, now) + require.NoError(t, Materialize(inst, CategoryQuota, quota, now)) + require.NoError(t, ApplyCommand(materialize(t, inst, 110))) + + fromRules, err := Stored(inst, CategoryQuota) + require.NoError(t, err) + require.NotNil(t, fromRules) + assert.Equal(t, BannerIDQuotaExceeded, fromRules.BannerID) + require.NotNil(t, storedBanner(t, inst)) + + // And the quota slot stays the stack's own, whatever the queue says. + fromQueue := materialize(t, inst, 111) + fromQueue.Category = CategoryQuota + assert.ErrorIs(t, ApplyCommand(fromQueue), ErrInvalidCommand) + fromRules, err = Stored(inst, CategoryQuota) + require.NoError(t, err) + require.NotNil(t, fromRules) + assert.Equal(t, BannerIDQuotaExceeded, fromRules.BannerID) + }) +} + +func TestApplyCommandToAnOrganization(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + useCommandContexts(t) + + orgCommand := func(t *testing.T, orgDomain string, revision int64) Command { + t.Helper() + cmd := fixture("organization") + cmd.Domain = orgDomain + cmd.Revision = revision + return cmd + } + + t.Run("every instance of the organization gets the banner", func(t *testing.T) { + orgDomain := fmt.Sprintf("acme-%d.example", time.Now().UnixNano()) + first := newInstance(t, commandContext, "en", orgDomain) + second := newInstance(t, commandContext, "fr", orgDomain) + + require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + + for _, inst := range []*instance.Instance{first, second} { + stored := storedBanner(t, inst) + require.NotNil(t, stored, inst.Domain) + assert.Equal(t, "billing.restricted", stored.BannerID) + } + assert.Equal(t, "fr", storedBanner(t, second).Lang, "each member reads its own language") + }) + + t.Run("a replay reaches a member provisioned after the command", func(t *testing.T) { + orgDomain := fmt.Sprintf("acme-%d.example", time.Now().UnixNano()) + first := newInstance(t, commandContext, "en", orgDomain) + require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + before := storedBanner(t, first) + require.NotNil(t, before) + + joined := newInstance(t, commandContext, "en", orgDomain) + require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + + require.NotNil(t, storedBanner(t, joined), "an equal revision resolves membership again") + assert.Equal(t, before.DocRev, storedBanner(t, first).DocRev, "and leaves the members it already reached alone") + }) + + t.Run("a refused category rejects the organization before any writes", func(t *testing.T) { + orgDomain := fmt.Sprintf("acme-%d.example", time.Now().UnixNano()) + accepting := newInstance(t, commandContext, "en", orgDomain) + refusing := newInstance(t, refusedContext, "en", orgDomain) + + err := ApplyCommand(orgCommand(t, orgDomain, 7)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidCommand) + assert.ErrorContains(t, err, refusing.Domain) + assert.Nil(t, storedBanner(t, accepting)) + assert.Nil(t, storedBanner(t, refusing)) + for _, inst := range []*instance.Instance{accepting, refusing} { + stored, err := storedCommand(inst, CategoryBilling) + require.NoError(t, err) + assert.Nil(t, stored, "rejection must not advance a member's revision") + } + + conf := config.GetConfig() + conf.Contexts[refusedContext] = map[string]interface{}{ + "enable_banners": true, + "banner_command_categories": []interface{}{CategoryBilling}, + } + t.Cleanup(func() { + conf.Contexts[refusedContext] = map[string]interface{}{"enable_banners": true} + }) + + // A permanent rejection needs an explicit replay after configuration + // repair; it is not an automatic broker retry. + require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + assert.NotNil(t, storedBanner(t, accepting)) + assert.NotNil(t, storedBanner(t, refusing)) + }) + + t.Run("an organization with no instance is a no-op", func(t *testing.T) { + assert.NoError(t, ApplyCommand(orgCommand(t, fmt.Sprintf("empty-%d.example", time.Now().UnixNano()), 7))) + }) +} + +func TestCommandStateIsNotInTheAppWritableDoctype(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + useCommandContexts(t) + + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 42))) + + stored, err := storedCommand(inst, CategoryBilling) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, consts.BannerCommands, stored.DocType()) + assert.NotEqual(t, consts.Banners, stored.DocType(), + "an application allowed to record a dismissal must not reach the ordering record") + assert.Equal(t, int64(42), stored.Revision) + assert.False(t, stored.Clear) + assert.Equal(t, "banner-command-42", stored.EventID) +} diff --git a/model/banner/testdata/clear.json b/model/banner/testdata/clear.json new file mode 100644 index 00000000000..782f72c54c5 --- /dev/null +++ b/model/banner/testdata/clear.json @@ -0,0 +1,7 @@ +{ + "workplaceFqdn": "alice.twake.app", + "eventId": "banner-command-43", + "revision": 43, + "timestamp": 1788944400, + "category": "billing" +} diff --git a/model/banner/testdata/materialize.json b/model/banner/testdata/materialize.json new file mode 100644 index 00000000000..a67f2a0d831 --- /dev/null +++ b/model/banner/testdata/materialize.json @@ -0,0 +1,36 @@ +{ + "workplaceFqdn": "alice.twake.app", + "eventId": "banner-command-42", + "revision": 42, + "timestamp": 1788944400, + "category": "billing", + "bannerId": "billing.grace.cycle-a.attempt-2", + "severity": "warning", + "surface": "banner", + "priority": 150, + "dismissible": true, + "title": { + "en": "Payment failed", + "fr": "Échec du paiement" + }, + "text": { + "en": "We could not charge your card. Please update your payment method before 5 August 2026.", + "fr": "Nous n'avons pas pu débiter votre carte. Mettez à jour votre moyen de paiement avant le 5 août 2026." + }, + "cta": { + "label": { + "en": "Update payment method", + "fr": "Mettre à jour le moyen de paiement" + }, + "url": "https://manager.example.org/linagora/twake_prod/premium" + }, + "secondaryCta": { + "label": { + "en": "Contact support", + "fr": "Contacter le support" + }, + "url": "https://twake.app/support" + }, + "startsAt": "2026-08-01T00:00:00Z", + "endsAt": "2026-08-05T23:30:00Z" +} diff --git a/model/banner/testdata/organization.json b/model/banner/testdata/organization.json new file mode 100644 index 00000000000..7b65b0abbf3 --- /dev/null +++ b/model/banner/testdata/organization.json @@ -0,0 +1,27 @@ +{ + "domain": "acme.example", + "eventId": "banner-command-7", + "revision": 7, + "timestamp": 1788944400, + "category": "billing", + "bannerId": "billing.restricted", + "severity": "error", + "surface": "modal", + "priority": 200, + "dismissible": false, + "title": { + "en": "Your workspace is temporarily restricted", + "fr": "Votre espace de travail est temporairement restreint" + }, + "text": { + "en": "Some features are limited for your organization. Please contact your administrator.", + "fr": "Certaines fonctionnalités sont limitées pour votre organisation. Contactez votre administrateur." + }, + "cta": { + "label": { + "en": "Contact support", + "fr": "Contacter le support" + }, + "url": "https://twake.app/support" + } +} diff --git a/model/instance/instance.go b/model/instance/instance.go index 91a7743b56d..7edc3383b4f 100644 --- a/model/instance/instance.go +++ b/model/instance/instance.go @@ -901,6 +901,25 @@ func (i *Instance) HasBannersEnabled() bool { return false } +// AllowsBannerCategory reports whether the context settings allow the banner +// command queue to write a category on this instance. +func (i *Instance) AllowsBannerCategory(category string) bool { + ctxSettings, ok := i.SettingsContext() + if !ok { + return false + } + categories, ok := ctxSettings["banner_command_categories"].([]interface{}) + if !ok { + return false + } + for _, allowed := range categories { + if name, ok := allowed.(string); ok && name == category { + return true + } + } + return false +} + func (i *Instance) HasPremiumLinksEnabled() bool { if ctxSettings, ok := i.SettingsContext(); ok { if enabled, ok := ctxSettings["enable_premium_links"].(bool); ok { diff --git a/model/permission/doctype.go b/model/permission/doctype.go index e2869190a85..020263cde98 100644 --- a/model/permission/doctype.go +++ b/model/permission/doctype.go @@ -21,6 +21,7 @@ var blockList = map[string]bool{ consts.RemoteSecrets: none, // Only stack can manipulate them + consts.BannerCommands: none, consts.Sessions: none, consts.Permissions: none, consts.Intents: none, diff --git a/pkg/consts/doctype.go b/pkg/consts/doctype.go index de008f18ba6..2bf97dfabab 100644 --- a/pkg/consts/doctype.go +++ b/pkg/consts/doctype.go @@ -18,6 +18,9 @@ const ( AppLogs = "io.cozy.apps.logs" // Banners doc type for the platform messages displayed to the user Banners = "io.cozy.banners" + // BannerCommands stores the last accepted command per category, separate + // from Banners so an app with dismissal access cannot rewrite the ordering. + BannerCommands = "io.cozy.banners.commands" // Konnectors doc type for konnector application manifests Konnectors = "io.cozy.konnectors" // KonnectorsMaintenance doc type for maintenance of konnectors. diff --git a/pkg/rabbitmq/banner_commands_test.go b/pkg/rabbitmq/banner_commands_test.go new file mode 100644 index 00000000000..3a920cd1146 --- /dev/null +++ b/pkg/rabbitmq/banner_commands_test.go @@ -0,0 +1,184 @@ +package rabbitmq_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/cozy/cozy-stack/model/banner" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/rabbitmq" + "github.com/cozy/cozy-stack/tests/testutils" + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBannerCommandsThroughTheBroker runs the queue as it is configured in +// cozy.example.yaml. It is the only place the two halves meet: a command no +// retry can fix has to end up in the dead letter queue rather than come back on +// the queue forever, and that is the broker's delivery limit doing it, not the +// handler. +func TestBannerCommandsThroughTheBroker(t *testing.T) { + if testing.Short() { + t.Skip("integration test skipped with --short") + } + + config.UseTestFile(t) + testutils.NeedCouchdb(t) + + const ( + dlxName = "stack.platform.dlx" + dlqName = "stack.dead.letter.banner.commands" + ) + + contextName := "banner-commands-broker-test" + conf := config.GetConfig() + previous := conf.Contexts + conf.Contexts = map[string]interface{}{ + contextName: map[string]interface{}{ + "enable_banners": true, + "banner_command_categories": []interface{}{"billing"}, + }, + } + t.Cleanup(func() { conf.Contexts = previous }) + + domain := fmt.Sprintf("banner-broker-%d.example", time.Now().UnixNano()) + _, err := lifecycle.Create(&lifecycle.Options{ + Domain: domain, + Email: "alice@example.org", + ContextName: contextName, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) + + MQ := testutils.StartRabbitMQ(t, false, false) + defer MQ.Stop(context.Background(), 30*time.Second) + + exchangeCfg := config.RabbitExchange{ + Name: rabbitmq.ExchangePlatform, + Kind: "topic", + Durable: true, + DeclareExchange: true, + Queues: []config.RabbitQueue{{ + Name: rabbitmq.QueueBannerCommands, + Declare: true, + DeclareDLX: true, + DeclareDLQ: true, + DLXName: dlxName, + DLQName: dlqName, + DLRoutingKey: "banner.commands.dead", + Prefetch: 8, + DeliveryLimit: 5, + Bindings: []string{ + rabbitmq.RoutingKeyBannerMaterialize, + rabbitmq.RoutingKeyBannerClear, + }, + }}, + } + + specs := rabbitmq.BuildExchangeSpecs([]config.RabbitExchange{exchangeCfg}) + require.Len(t, specs, 1) + require.Len(t, specs[0].Queues, 1, "the queue name must still map to a handler") + + connection, err := rabbitmq.BuildConnection(config.RabbitMQNode{Enabled: true, URL: MQ.AMQPURL}) + require.NoError(t, err) + mgr, err := rabbitmq.NewRabbitMQManager(connection, specs).Start(testCtx(t)) + require.NoError(t, err) + defer mgr.Shutdown(testCtx(t)) + require.NoError(t, mgr.WaitReady(testCtx(t))) + + _, ch := testutils.CreateRabbitConnection(t, MQ) + defer ch.Close() + + publish := func(t *testing.T, key string, body []byte) { + t.Helper() + require.NoError(t, ch.PublishWithContext(testCtx(t), rabbitmq.ExchangePlatform, key, false, false, + amqp.Publishing{DeliveryMode: amqp.Persistent, ContentType: "application/json", Body: body})) + } + + command := func(t *testing.T, name string, revision int64, edit func(map[string]interface{})) []byte { + t.Helper() + raw, err := os.ReadFile("../../model/banner/testdata/" + name + ".json") + require.NoError(t, err) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &payload)) + payload["workplaceFqdn"] = domain + payload["revision"] = revision + if edit != nil { + edit(payload) + } + body, err := json.Marshal(payload) + require.NoError(t, err) + return body + } + + stored := func() *banner.Banner { + inst, err := lifecycle.GetInstance(domain) + if err != nil { + return nil + } + doc, err := banner.Stored(inst, banner.CategoryBilling) + if err != nil { + return nil + } + return doc + } + + t.Run("a materialize published on the platform exchange reaches the instance", func(t *testing.T) { + publish(t, rabbitmq.RoutingKeyBannerMaterialize, command(t, "materialize", 1, nil)) + + testutils.WaitForOrFail(t, 20*time.Second, func() bool { return stored() != nil }) + assert.Equal(t, "billing.grace.cycle-a.attempt-2", stored().BannerID) + }) + + t.Run("a clear removes it, and a redelivered older command does not bring it back", func(t *testing.T) { + publish(t, rabbitmq.RoutingKeyBannerClear, command(t, "clear", 2, nil)) + testutils.WaitForOrFail(t, 20*time.Second, func() bool { return stored() == nil }) + + publish(t, rabbitmq.RoutingKeyBannerMaterialize, command(t, "materialize", 1, nil)) + // The queue processes serially. A rejected marker proves the stale + // command finished without overwriting the state we want to inspect. + publish(t, "banner.materialize", []byte(`{"eventId":"stale-replay-barrier"}`)) + dead, ok := testutils.GetOneFromQueue(t, MQ, dlqName, 30*time.Second) + require.True(t, ok) + require.Contains(t, string(dead.Body), "stale-replay-barrier") + assert.Nil(t, stored(), "a stale delivery must leave the category cleared") + }) + + t.Run("a malformed command reaches the dead letter queue", func(t *testing.T) { + publish(t, rabbitmq.RoutingKeyBannerMaterialize, []byte(`{"category":"billing",`)) + + dead, ok := testutils.GetOneFromQueue(t, MQ, dlqName, 30*time.Second) + require.True(t, ok, "a payload no retry can fix must not be requeued forever") + assert.Contains(t, string(dead.Body), `"category":"billing"`) + assertDeadLettered(t, dead) + }) + + t.Run("a command for a category the context refuses reaches the dead letter queue", func(t *testing.T) { + publish(t, rabbitmq.RoutingKeyBannerMaterialize, command(t, "materialize", 4, func(p map[string]interface{}) { + p["category"] = "trial" + })) + + dead, ok := testutils.GetOneFromQueue(t, MQ, dlqName, 30*time.Second) + require.True(t, ok) + assert.Contains(t, string(dead.Body), `"category":"trial"`) + assertDeadLettered(t, dead) + }) +} + +func assertDeadLettered(t *testing.T, dead *amqp.Delivery) { + t.Helper() + deaths, ok := dead.Headers["x-death"].([]interface{}) + require.True(t, ok, "the message must carry the broker's dead letter record") + require.NotEmpty(t, deaths) + death, ok := deaths[0].(amqp.Table) + require.True(t, ok) + // A quorum queue dead letters on its delivery limit, so the reason names + // the limit rather than the single rejection a handler could ask for. + assert.Equal(t, "delivery_limit", death["reason"]) +} diff --git a/pkg/rabbitmq/contracts.go b/pkg/rabbitmq/contracts.go index 1c9ab65d56a..8198b2d4929 100644 --- a/pkg/rabbitmq/contracts.go +++ b/pkg/rabbitmq/contracts.go @@ -5,6 +5,8 @@ const ( ExchangeBilling = "billing" ExchangeB2B = "b2b" ExchangeMigration = "migration" + // ExchangePlatform carries the banner commands. + ExchangePlatform = "platform" ) const ( @@ -17,7 +19,7 @@ const ( QueueB2BUserDeleted = "stack.b2b.user.deleted" QueueB2BGroupLifecycle = "stack.b2b.group.lifecycle" QueueAppCommands = "stack.app.commands.queue" - QueueBillingLifecycle = "stack.billing.lifecycle" + QueueBannerCommands = "stack.banner.commands" ) const ( @@ -31,35 +33,10 @@ const ( RoutingKeyUserDeletionRequested = "user.deletion.requested" RoutingKeyNextcloudMigrationRequested = "nextcloud.migration.requested" RoutingKeyNextcloudMigrationCanceled = "nextcloud.migration.canceled" - RoutingKeyPaymentFailed = "payment.failed" - RoutingKeyPaymentRecovered = "payment.recovered" + RoutingKeyBannerMaterialize = "banner.materialize" + RoutingKeyBannerClear = "banner.clear" ) -// BillingLifecycleMessage is published by the Cloudery when a payment event -// changes what the user has to be told, not what they are allowed to do: -// access control stays with the Cloudery. -// -// Exactly one of Domain and WorkplaceFqdn is set. Domain is a B2B -// organization, and every instance under it gets the banner. -type BillingLifecycleMessage struct { - Domain string `json:"domain,omitempty"` - WorkplaceFqdn string `json:"workplaceFqdn,omitempty"` - - // Status is the subscription status as Stripe reports it, verbatim, read - // back from the live subscription rather than from the event snapshot. - Status string `json:"status"` - // AttemptCount is the invoice attempt_count, passed through untouched. It - // returns to 1 when a new invoice opens. - AttemptCount int `json:"attemptCount,omitempty"` - // EventID is the Stripe event id, logged so a displayed banner can be - // traced back to what produced it. - EventID string `json:"eventId"` - // Timestamp is the Stripe event's own created time, in epoch seconds. - // Delivery is at-least-once and unordered, so this, not the arrival time, - // decides which event wins. - Timestamp int64 `json:"timestamp"` -} - // UserDeletionRequestedMessage is published when a user asks Twake to delete the account linked to the current cozy instance. type UserDeletionRequestedMessage struct { WorkplaceFqdn string `json:"workplaceFqdn"` diff --git a/pkg/rabbitmq/handlers.go b/pkg/rabbitmq/handlers.go index 3aadd3c5d18..68e047190cf 100644 --- a/pkg/rabbitmq/handlers.go +++ b/pkg/rabbitmq/handlers.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "strings" - "time" amqp "github.com/rabbitmq/amqp091-go" @@ -837,66 +836,47 @@ func maskSensitiveData(data string) string { return data[:3] + strings.Repeat("*", len(data)-6) + data[len(data)-3:] } -// BillingLifecycleHandler materializes the banner that tells a user their -// payment is failing. -type BillingLifecycleHandler struct{} +// BannerCommandHandler adapts banner.ApplyCommand for the message bus. +type BannerCommandHandler struct{} -func NewBillingLifecycleHandler() *BillingLifecycleHandler { - return &BillingLifecycleHandler{} +func NewBannerCommandHandler() *BannerCommandHandler { + return &BannerCommandHandler{} } -func (h *BillingLifecycleHandler) Handle(ctx context.Context, d amqp.Delivery) error { +func (h *BannerCommandHandler) Handle(ctx context.Context, d amqp.Delivery) error { log := logger.WithNamespace("rabbitmq") - log.Infof("billing.lifecycle: received message: %s", d.RoutingKey) - - var msg BillingLifecycleMessage - if err := json.Unmarshal(d.Body, &msg); err != nil { - return fmt.Errorf("billing.lifecycle: failed to unmarshal message: %w", err) + log.Infof("banner.commands: received message: %s", d.RoutingKey) + if len(d.Body) > banner.MaxCommandBytes { + return fmt.Errorf("%w: banner command exceeds %d bytes", banner.ErrInvalidCommand, banner.MaxCommandBytes) } - // A malformed message must fail here rather than silently update nothing, - // or a whole organization when one instance was meant. - if (msg.Domain == "") == (msg.WorkplaceFqdn == "") { - return fmt.Errorf("billing.lifecycle: exactly one of domain and workplaceFqdn is required, event %s", msg.EventID) - } - // Events are ordered by this, so one without it cannot be placed. Zero - // would read as 1970 and lose against anything stored, which for a - // recovery means a banner that never clears. - if msg.Timestamp <= 0 { - return fmt.Errorf("billing.lifecycle: timestamp is required, event %s", msg.EventID) + var cmd banner.Command + if err := json.Unmarshal(d.Body, &cmd); err != nil { + return fmt.Errorf("banner.commands: failed to unmarshal message: %w", err) } - // A recovery says the subscription is paying again whatever the payload - // carries, and that is what makes the banner go away. - status := msg.Status - if d.RoutingKey == RoutingKeyPaymentRecovered { - status = "active" + // The routing key decides what to do, never the payload, so a producer + // cannot clear a category by naming a field. + switch d.RoutingKey { + case RoutingKeyBannerMaterialize: + case RoutingKeyBannerClear: + cmd.Clear = true + default: + return fmt.Errorf("banner.commands: unexpected routing key %s", d.RoutingKey) } - var domains []string - if msg.Domain == "" { - domains = []string{msg.WorkplaceFqdn} - } else { - list, err := lifecycle.ListOrgInstances(msg.Domain) - if err != nil { - return fmt.Errorf("billing.lifecycle: could not list instances for organization %s: %w", msg.Domain, err) - } - for _, inst := range list { - domains = append(domains, inst.Domain) - } - if len(domains) == 0 { - log.Infof("billing.lifecycle: no instances found for organization %s", msg.Domain) - return nil - } + // A command the queue cannot fix is nacked like any other failure. The + // queue is declared with a delivery limit, so the broker dead letters it + // after those attempts rather than redelivering it forever. + if err := banner.ApplyCommand(cmd); err != nil { + return fmt.Errorf("banner.commands: %s revision %d: %w", d.RoutingKey, cmd.Revision, err) } - eventAt := time.Unix(msg.Timestamp, 0).UTC() - for _, domain := range domains { - if err := banner.RefreshBilling(domain, status, eventAt); err != nil { - return fmt.Errorf("billing.lifecycle: materialize for %s: %w", domain, err) - } - log.Infof("billing.lifecycle: %s applied to %s (status %s, attempt %d, event %s, at %s)", - d.RoutingKey, domain, status, msg.AttemptCount, msg.EventID, eventAt.Format(time.RFC3339)) + target := cmd.WorkplaceFqdn + if target == "" { + target = cmd.Domain } + log.Infof("banner.commands: %s applied to %s (category %s, revision %d, event %s)", + d.RoutingKey, target, cmd.Category, cmd.Revision, cmd.EventID) return nil } diff --git a/pkg/rabbitmq/handlers_test.go b/pkg/rabbitmq/handlers_test.go index d337817a1bb..1256e3e34c9 100644 --- a/pkg/rabbitmq/handlers_test.go +++ b/pkg/rabbitmq/handlers_test.go @@ -4,14 +4,18 @@ import ( "context" "encoding/json" "fmt" + "os" + "strings" "testing" "time" + "github.com/cozy/cozy-stack/model/banner" "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/rabbitmq" "github.com/cozy/cozy-stack/tests/testutils" amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -95,3 +99,118 @@ func TestUserCreatedHandlerStoresMatrixID(t *testing.T) { require.Empty(t, storedMatrixID(t, domain)) }) } + +func TestBannerCommandHandler(t *testing.T) { + config.UseTestFile(t) + testutils.NeedCouchdb(t) + + contextName := "banner-command-handler-test" + conf := config.GetConfig() + previous := conf.Contexts + conf.Contexts = map[string]interface{}{ + contextName: map[string]interface{}{ + "enable_banners": true, + "banner_command_categories": []interface{}{"billing"}, + }, + } + t.Cleanup(func() { conf.Contexts = previous }) + + domain := fmt.Sprintf("banner-handler-%d.example", time.Now().UnixNano()) + _, err := lifecycle.Create(&lifecycle.Options{ + Domain: domain, + Email: "alice@example.org", + ContextName: contextName, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) + + fixture := func(t *testing.T, name string, revision int64) []byte { + t.Helper() + raw, err := os.ReadFile("../../model/banner/testdata/" + name + ".json") + require.NoError(t, err) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &payload)) + payload["workplaceFqdn"] = domain + payload["revision"] = revision + body, err := json.Marshal(payload) + require.NoError(t, err) + return body + } + + handle := func(t *testing.T, key string, body []byte) error { + t.Helper() + return rabbitmq.NewBannerCommandHandler(). + Handle(context.Background(), amqp.Delivery{RoutingKey: key, Body: body}) + } + + stored := func(t *testing.T) *banner.Banner { + t.Helper() + inst, err := lifecycle.GetInstance(domain) + require.NoError(t, err) + doc, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + return doc + } + + t.Run("the routing key materializes, then clears", func(t *testing.T) { + require.NoError(t, handle(t, rabbitmq.RoutingKeyBannerMaterialize, fixture(t, "materialize", 1))) + require.NotNil(t, stored(t)) + + require.NoError(t, handle(t, rabbitmq.RoutingKeyBannerClear, fixture(t, "clear", 2))) + require.Nil(t, stored(t)) + }) + + t.Run("a payload that does not parse fails", func(t *testing.T) { + err := handle(t, rabbitmq.RoutingKeyBannerMaterialize, []byte("{")) + require.Error(t, err) + }) + + t.Run("an unexpected routing key fails", func(t *testing.T) { + err := handle(t, "banner.something", fixture(t, "materialize", 3)) + require.Error(t, err) + }) + + t.Run("an invalid command fails", func(t *testing.T) { + body := fixture(t, "materialize", 4) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(body, &payload)) + payload["severity"] = "critical" + body, err := json.Marshal(payload) + require.NoError(t, err) + + err = handle(t, rabbitmq.RoutingKeyBannerMaterialize, body) + require.Error(t, err) + assert.ErrorIs(t, err, banner.ErrInvalidCommand) + }) + + t.Run("an oversized body is rejected before decoding", func(t *testing.T) { + err := handle(t, rabbitmq.RoutingKeyBannerMaterialize, []byte(strings.Repeat(" ", banner.MaxCommandBytes+1))) + assert.ErrorIs(t, err, banner.ErrInvalidCommand) + }) + + t.Run("invalid times never advance desired state", func(t *testing.T) { + body := fixture(t, "materialize", 100) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(body, &payload)) + payload["timestamp"] = 1788944400000 + body, err := json.Marshal(payload) + require.NoError(t, err) + err = handle(t, rabbitmq.RoutingKeyBannerMaterialize, body) + assert.ErrorIs(t, err, banner.ErrInvalidCommand) + // Reusing that revision with valid content succeeds only if the bad + // command was rejected before the desired-state write. + require.NoError(t, handle(t, rabbitmq.RoutingKeyBannerMaterialize, fixture(t, "materialize", 100))) + }) + + t.Run("a target that is not here yet is redelivered", func(t *testing.T) { + body := fixture(t, "materialize", 5) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(body, &payload)) + payload["workplaceFqdn"] = fmt.Sprintf("provisioning-%d.example", time.Now().UnixNano()) + body, err := json.Marshal(payload) + require.NoError(t, err) + + err = handle(t, rabbitmq.RoutingKeyBannerMaterialize, body) + require.Error(t, err, "a workplace still being provisioned deserves another attempt") + }) +} diff --git a/pkg/rabbitmq/rabbitmq.go b/pkg/rabbitmq/rabbitmq.go index 84d199e726a..46f80991c8c 100644 --- a/pkg/rabbitmq/rabbitmq.go +++ b/pkg/rabbitmq/rabbitmq.go @@ -275,8 +275,8 @@ func BuildExchangeSpecs(exchangesCfg []config.RabbitExchange) []ExchangeSpec { handler = NewB2BGroupLifecycleHandler() case QueueAppCommands: handler = NewAppInstallHandler() - case QueueBillingLifecycle: - handler = NewBillingLifecycleHandler() + case QueueBannerCommands: + handler = NewBannerCommandHandler() } if handler == nil { diff --git a/web/data/banner_commands_test.go b/web/data/banner_commands_test.go new file mode 100644 index 00000000000..fc63c43ae77 --- /dev/null +++ b/web/data/banner_commands_test.go @@ -0,0 +1,69 @@ +package data + +import ( + "testing" + "time" + + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/tests/testutils" + "github.com/stretchr/testify/require" +) + +func TestBannerCommandStateIsPrivate(t *testing.T) { + if testing.Short() { + t.Skip("requires CouchDB") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + ts := setup.GetTestServer("/data", Routes) + t.Cleanup(ts.Close) + state := &couchdb.JSONDoc{Type: consts.BannerCommands, M: M{"_id": "banner-billing", "revision": 42}} + require.NoError(t, couchdb.CreateNamedDocWithDB(inst, state)) + public := &couchdb.JSONDoc{Type: consts.Banners, M: M{"_id": "banner-billing", "dismissedAt": nil}} + require.NoError(t, couchdb.CreateNamedDocWithDB(inst, public)) + + for _, scope := range []string{consts.BannerCommands + " " + consts.Banners, "io.cozy.banners.*"} { + t.Run(scope, func(t *testing.T) { + _, token := setup.GetTestClient(scope) + e := testutils.CreateTestClient(t, ts.URL) + path := "/data/" + consts.BannerCommands + for _, req := range []struct { + method, suffix string + body interface{} + }{ + {"GET", "/banner-billing", nil}, + {"PUT", "/banner-billing", state.M}, + {"PUT", "/new-command", M{"revision": 999}}, + {"POST", "/", M{"revision": 999}}, + {"DELETE", "/banner-billing", nil}, + {"DELETE", "/", nil}, + {"GET", "/_all_docs", nil}, + {"POST", "/_find", M{"selector": M{}}}, + {"POST", "/_bulk_docs", M{"docs": []interface{}{state.M}}}, + {"POST", "/_bulk_get", M{"docs": []interface{}{M{"id": state.ID()}}}}, + {"GET", "/_changes", nil}, + } { + r := e.Request(req.method, path+req.suffix).WithHeader("Authorization", "Bearer "+token) + if req.method == "DELETE" && req.suffix == "/banner-billing" { + r.WithQuery("rev", state.Rev()) + } + if req.body != nil { + r.WithJSON(req.body) + } + r.Expect().Status(403) + } + // Reserving the internal state must not remove public dismissal access. + require.NoError(t, couchdb.GetDoc(inst, consts.Banners, public.ID(), public)) + public.M["dismissedAt"] = time.Now().UTC().Format(time.RFC3339) + e.PUT("/data/"+consts.Banners+"/"+public.ID()). + WithHeader("Authorization", "Bearer "+token).WithJSON(public.M).Expect().Status(200) + }) + } + var unchanged couchdb.JSONDoc + require.NoError(t, couchdb.GetDoc(inst, consts.BannerCommands, state.ID(), &unchanged)) + require.Equal(t, state.Rev(), unchanged.Rev()) +} diff --git a/web/statik/statik.go b/web/statik/statik.go index ed2fb0462ad..dbb464e8643 100644 --- a/web/statik/statik.go +++ b/web/statik/statik.go @@ -36124,166 +36124,163 @@ deeiMJr9O27139iq8mkpHifmQ9cQo5xEXoV3nmKLHfCId33mnKpyi7C7aRJkkw/h -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /locales/en.po -Size: 38055 +Size: 37503 -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 -TMv7U6XUAa1Bax8D +G36SAKwHeCKy0LuIevZhQcN2WHjcVCyqolbQ2C+IM8v3p1ZVq6l29+7whTKFpp+A +bE+AD0/6gD0BAg45YL1wqy1K0xte79rHRVlCNlVt9zIpvkNKF79Pf3SnSyO/FahF ++BcBn0nZzrCtiTnfVF+n9dlduk+6TlVZlmSqiPgs4qITeAJcOGyr4f9P5ee5fPsf +1isOeZRTaSOeVaZWbwppONqylLK0duzbqsquCGJBxC8IMkKflLx9oauruoYgCKzk +boA8hX0hpXcy/Dc8h3eO9Ye0GZ1AqUD7wiYTs7Es93j5XVTR7Xb8u3jsn1/f4fp+ +M0kJ9+imB37+3u3P6v7+2+dL4OxTu59sB3AohCKJmFCPDNz/ECtlGkBhs84/mqq+ +LusvX3So1C7o/HCP1RaFxZGxgOccg4yFZS54qv4UVAtGx7LG8Vz8RRXxuPfsNxv+ +ZxtkQf5x7erUkCSsuQVH689ac4btBxX2fohTHttAVLtcHFtN6ZeE15xigHmW/tVn +1TRNCVGLxPnMXAvynNZEkpeMbW48/1t/UYnQc/KlJwNl1U2GVVTAcVbzR4R8K1K3 +mHaIWVoaZ6hS1om0yIld7siDYrSbLz8UYJNlgOPJaQ6GjwjHxiXBP8s0HTXspQDb +2K0OTfcVwlysao+7HVQnSkWvcVvqqdJPhFSjvugEwd8YEgCL3kUbYjMcm9NNHpqv +SVeeGXU8sxKd8TQw2y5cI5nf5VibHaq7zIVmMmfa2fukupQ07/om0ZQp+LCZS5ls +/VqPIAp8j6AwDyn1twtPmtUCiY8cgIwHRnOvQ3O8gVkglHHlz4N7vMZbXOEbONzy +SRJwm3XV/Aikm9BAvLsxgACsMxQyC0o6L2x0O93WTSIGXSLAr7MJGwf7vN7Dh3j1 +JvjsQ/SRNHsPso9kVG0vfzzFQGGJGlcKtbjr44LIMhYPVtPBDf7mlnhzYw7J4fi3 +DYIXUYwcvnQQ6U+XtZfOOrCG2juY2UwrCANMb545dfGwZSS2xqpjLBnWPx7WlsXw +wtFWRGFPJ6lNHQNtRRD6OgCPXABAUzVgLseyA9imO7a64rn/9bplJUIjE6iewiZp +BNPUxnyeZ1PGrlgZQ65z499cWI+W+ZudLOqHx/UsWpgBAeUY11dnYl4qW7/dipFW +a7Yu32KviIf0wbKXOrRwRpmHxL37qHsYoYmv6PI9InO7oZrj+THPj7xqVteGeVie +l9d63X4JQJGtbaH/FzmKCyJDJPONx5Tz85MZC5kPXqekznNLPY3u3elOLtfyCxsn +Mie4bE2LbDa9s1AIP9+Wi9NsO04zO+nYazyNo2hws3+E1WjV86d+qLhbxjG9af5U +q+J9B+ZCW4hCwi51dJWvV1b8mrOcsgjBi4tCanRpmcJYTQ3a4CSBwqOIawWtUKrZ +3gufsb4vB5Jpu3iHB5tx0oKEGY0aVvwvip4fBhOAdJSUcfR4+3uo99FAU8dLHzlS +YdVykNvZAdIN7jtBoh/aXC+qhfPtThAVdat9WjK+pi5EHqGKlg+w97jZlde/2xu+ +120ekBmqfNzPCtjM2S+70PfZYeYk+GpUmUwMeE88iRrEiy72ed+rd3l1fMc75izq +D3SVekc4ixjsK1S7VoYplGFBnX/KPh0tpgkXuYwEfgXWhp+Mh+HP4x5Wz8QW/DUk +dlckikgxDNIeBmlAVBAEmI+KDVmyMZMwubTmCZcf/ZGWojBIuWtOInpONjd8ATMi +6MhRm8X23Hn/dzIX2dEGH8lnG9iPI09Wu2JYC5NCtFz5gUPtgRH5xx6AMLWh/0fi +sy6tGTCcmuZz8VOgSIjkdUCj8h60dJAOFVf6mHwM0wqVxORhR3CCFcOhBfW2Idwc +7REcqcEwIegsktRxkYtPHg0zyIsqR5SMQtXDwxk44lUmEWyAsYrL6dqZB3BIoKjS +ThStHn1TWA5cBo/IK/AXV0tSBWIN1IiayXjVL4LCLj8sL9pH07qZWKnrwLj2LPuP +gsXNCLQfok4vW1iNLuCaToMJa+gN7bfVj96OUfeNx9h2lU4zu+6uh0RduUuKXQ6S +cfaHxDx4sQ2WgP57eaLnZSAgM+8mCPVaPS+B0ax1ouJJpsU6yuVE4WCYp+DKXJOD +999yKOIMRcIXz5lJEQdj7xMF2+UOa3G/RWwEcbJrcLjl1mtJ3/mVA1F0uyNb6Vfb +d10LEDzTug2Op/4ODTch0mRx4mTsXFZzdJ5jXz6dVdaWWkD6AcYx8yNiJY7XJ72s +By8Jc79YXgohgb6WId4itjfp4eNP6PYAIf0MfdeTWZjzWosvH0lRAEFod6tVOHOz +m8+4qB7zdF5wk57Daqp5wzLSYbO2aTGCeB2vrnh6UAS05at5St1CyRpzfwjtJuEE +ywJg3QDXGBZY3LQhuyqefXm0ROnhiFtEC3ENgICPPrCzwSuuogtaFJQZwA82p2Dl +au8yEUpDKldmwfgZsdlarx9qYwXPRT9C511Wt80V9N+EHrJC5AdrlqV5+RlkGEQ+ +boZujU5yI2qcoxXn0AJhsZtyUtdRKgD7Piy4dksr78YRppzjB2+xXHgMKm2xenrz +A7anRbz1op4xY38w+TuE3NFBLIgjisL85deIejR6+Mn3XQegvwnrA68KNTw8ln0B +phXAr/3CRWqbKLSKPInCog8z4ys8nlCgka+BCD9U8a0Fthmc1yB56E7z6P/BSqdF +Mh1LRUg4pS0VsVUhlCpvDYHm1OxGTp5XuZ8HeGA9TqmJNufVsV26fewo17BndKJQ +bZmqN5Ou8UAHGR4v2Fypq4lujCS0VI05qAWBe9RtgL+T11iiqy24YEsI/0DRnj3f +ykeYnxTZgrFf+GAairuaADMcKo6GPCA82dqJ8a0NDdB9xB21Wx9RAERC5EOBzK0G +/+d//xXjxZmKZSlupoP1ao+VTtbV7CKVAc/dzQeRZaScWd0/T9km6kbFEiT2YFRO +14V48/j4Wm/+dzulIHEVUtdhLoL7C2PjeSQlINTVCkqYU6aoMPHlXaJOn0EYxNT+ +0SOrBHpp35bt2rh2KYq4Ctv02UdkpEYhGmGmrJaqQ1Su6uOCTjzzQTSg8mr2FHmt +XkRZX0dxTJ6zplrtz/Xc3ohc7JuvHXwck+j/a/wJo4dNlm+OiBPzmzDjmt/iwRSm +2ASRHs2b5iJd5WoAp5Gky3EHh6sd96zW8G+bH+PI639bvNjABT328q4SUeKuswy3 +668XiRgpwq2tA33HbcWuW5rydxWOs3hwTI6zb6TNdqNqHJjmQH7Qb1yXX1WObxRa +GCg0oheqT6DfFiygiVjL0D12gKvlGsG6yevQ4aMLDWnj7NqvmnjkLgbIrlttOFaI +PPVvbr52ekyMmqzHFBljqD7rEcfe+uja4JgFzSd1I/QbuJb/08f7Se9qGyks7OSu +c2eQ4FWMqLxNrux3Leo3TRwoFCMCgGEsiYQpnLAMEe2EVJw0uSFrHWFtWM9UHOxW +49Q29NGEfM4SAIUtp4KWdkmX6qAPzIh6q2iLaCvcsV+IuHdCutRuG7eKX7mD7tYI +cIJa/V+XRj7/urTObqgm4gx/kxluNz7e5pXOPN76k9fXJLqjByQDM/37HsfxZxfg +27wWjqdC2yMIwOzA4/aKH2KFwOU/NwyQj9wmuR+wcEUzNGRCZtIULh/T9JIJVk00 +szqh8dLo7WnexIEEvCnIXB5sRhWvyIu9msuxR826VRTEr5KLN2dT88lrMm6xuMtq +e2+eDLlLrzRdZL27YAoVuHBgi4H//ldS/YvZD9aCN0yEio520aBH3KFYzdMbV82w +fVTQgYGqdPduE0Rs/VC3HXY8M8NUSOw3WU5bu98QvMiRK/5RJHZGY89CroOKJlG5 +AOHMhzCZD85tKw8hKYDrvlwrVnDrXAjbOBXKLajXumkUumtQaFKFwn+H6GgEfkRh +uMCcSN0ownGuq9vthpHQtypepPhA/5+zFGIgAM1wvRJ04VjK1UIc4EKxDAF2WNJY +esFzDc89wza9I8h+4KdxY+3aDWfCYKbJSaXSeAtV1BV1XuESmnLVLzDxrAjIiVBS +8lBj3JABvUcxeATR3RIcb8WnQxlwopGnHZizySIRxHZQCplUgF2EXgRL1znwTHQH +Wnk4/soUOLi6szEuxMF88sHYN89P/Golxr3C+bJbRtZZ45PxhwMkrEKWDCo7A9mA +uOZAy+CcKH5JRlcAHQkf/I4R3dCFVoP6fVysRI1kC0KCZXNqwmgFUSiUgB2ch9+Z +RcWa2zSDPuOZjUU5NeUiABLNsN2WIZnW/dtF+UHe5UhFpDEYmFKQ1fNqVpsucE/m +5bOQ/mR0jIBFS4KniMK8qKq4xa2uQCR+eCv6Y4JwrhWWTlqEj2vJQZMarZ8yxE/O +OMQAIH2+xR84JwUjBKcaIa5zS8VwKSm7kcFTQ3YNkBt/nQ+K0KzDE5q9h/NHFaZn +KA+3vyfD+57+vJl++ObyP0yS2ARWDeThGdaECGcUdsVNsjYicFi8a4zJ2lT+a9lg +7dexlY5HbV+Z95oN7vcioHefOnbtSsh7ZIVlI0+kGrgtRmI4kraBfRyK7Sm7R5k6 +FapRH05sNEpk76uKH3l2cDRkYwjZh3lhEBGsZ/708cUsw9q73wQCjG0uvPbvcuKN +NSfJkGQcWgfEo1U6Jk5i922sN8DegFhbhS6565Qgb4LRh1JTOc4xT/bKJYYMy9KO +K4T5LOuxjKXX+ALhZki6xwKd1FRHcTDw5ADxZ0xMkH/4bh36lqK6XtTqlTa6dmk8 +YH24fXvap7nO8YWjlBikN5eJ6ZCRWPxyM39LQaJfORgyf8iHsQs7IowuXT/e7GIo +WsG9AC9AM0UhCkdo/dlj3zCmOwRUJy7UsIUf3jFVAeIlIWOwvAN+t/4QY9FyiFTo +xaOBJr1owo/Cgv/msfCqvSlphcJ1VuaIbR5o6r2utLycezvsOOsfF3+BXZi6Y933 ++rSc2mFk1Yv+v3qRTbOkMJ5p1o05xtZIXXLiM2CuVsyusgGIQZDo8EXB2q/s38H+ +YHQnfvOT2lE0OOeIAWY80AAA2hKu4H/nes7M4UAiwTZKbsJMIiMqWsHONa6Pm+1n +Bg/bGzBMNcjCtP6QlRXKBhbsK0DhYyGAe3Nzvsy64oQKHuA0zWDUqMayIH/HUWul +XcBsJH35HBZLBabSvyuGEhFY4VvZflmhsnBSGG3YwoBnM3YvrM1eYeqh80zVfEEW +mt33hM0G5gA50BFYYkgShQ6ye3C0M0Dfc/YQcN8/widAhYdIcMexJBDlJ+IBqjia +Zz90QjKeYYSJNDDl+hKErHBjMwbzUIsM13dCPd5NxB3RtPpOnmq2xYyDHvvZvkRT +4d7jz0p1YdfPNMqrePYcmFctsvCshK3CssZ6G89mfitmlqNZ/dIf/BYLiA+8GB38 +GA5Ei39clhxhOK3JwVwslD2QeNExwLF2ODSLuvd5AhjhF+r8GJjnE4+wJR/0/3B/ +wplZSoCsmBW1hKs2ZgQjZDwrnI94Jycuh6CeHMkAuYgS04/IZbWzqZk7MV1fWVZf +LYRHa2m9XrOLssjhudY/Ud6Y3eYHYjFl7JHAuYQd8J6q3HrAqNS39NEDwETKmOft +zKvgfxEUYyYDbKl0unHCPP3sOKJyDMhAwtAm6kno0RjGg6uVs9yW/0GauBUB8gZW +YRj+B3C5yG0ZLlzMK/yPq5gJ48hi+wQcuEJ294OZ5aNBKzpypIdrh0BPO/suGzaR +ftbdTbVCvVhua/vy/AwWymcWW/KiWHKmK17N92PD+NLmRxm188HEz1w+MroV0VEU +oeroF9vcUB9jHq27Suwaa7TyLEPWmQWVOANWpu7KD9RwmOsC5A8iK/tdanfLQhJM +8bpw7v/tz/ttAAD5hP3cWbYp99tJ+9tzJoBxe4dxag6McGN86/kSByk+KmhfsjZJ +VJDW9onRbkHLyQrtwgmWYtStQ8jI6TBciMtMcM4Hx7oPVew0kLub0MA9k5u2VnaP +zrxVz+WUORaEnwFJkA1X3UgCLHOAhKnads0aBwju+hXNELeByGO3+xaB1+5ijeHV +0WmZqp7yK+B+z/Ll2QJ0Wd9vQsGOeGjmo7L98EzJH0GRUF2hpDFhbbyNkvl/mYpV +hq3soEGp1OUCyYrEJDiiKW910zDA2HvX2xithN645KqRMtyoA5D7ufCyhV7zEbF2 +mebbesFEt7WR6w79tBB581huJ4WdrCq1IZPe2DyFZbkzzFo837xiYchvWNhNyuEU +WavMvjakGfEXQ4Co0woqka8yNieoqX7Wbc3wBK/94sVcsyMoa5aFblyaLaGJLdeQ +tb/NDC9kHdXCXUcr3S0s54LVOkv17afOAdvsloGTSJqs7X2qJMQpVHvJy8WH2i4u +QIrpNi/n/V1XH/kQxLXDszhAnWRf/DE5l04tJwJ55UTHkG3zTH9iSFG9STKdeqFR +pD3kKQL3KJWUqCaGRUB6N5EG0o1F0mvS+dsNHiRO+6WnsMpXUt3Ydr7M1JYytdiP +PkGgrBfm3M2+gNiwEV4YizYEqT03KJF14bjfIzq0wqXdr+J4zTvhGHJDp/FnZYso +GO3sqSdpHwLOmr5lnu59gx4B8sH9BCZZZL8VM/wI2If1Sqmi7CecyeSEjN0JKvCi +Dt4cpjUihQHAhGdPV4qwT58dy+eK5zMQAWqH7Vt7ZkGtkkmQONcIZ33tyCagfAdd +m2Ni173bfRD+uQwSoyvpJDt7Ahyuv1UxiTlM6wlGkBgxsgKIjz1SK5UAbm03tDQl +tPVHE8MxIBBT+Pl44UWVDBPmDyCjtkJWOi2thS1UVEFx7rP7edly4EvzDxjg4m+l +FeHi0ljcFJiztGL+HPaT4YR7DftAd/wci6+bdgqdG+0XmkrklFG9fuBd4yLhqJCQ +ew5c8XBLkPdPuQFy6ve7LTeJjfTv7qPU+u+k/q6JSv3fLKOTvE9qXl7j7fz5Xtkx +gc3UyQmb1URZQHV7nI/6ShMGpLSHhhA318boP6rDq0SaN1aWk4kM9T3rmF/b1nS+ +EYy9bzrZgWn12mEqtbeZ7N6e3fH3WX5Xr3R/B4znzLv6ufu7gkndkUny1bLCjO8K +70RXwvDNSbxbHEX//qtWzBCade1VRiAhGCmY9q0No7BPCv95yIWqb6bHwGMlKty5 +5gI31YOX/AhbX0oRiaRh7O7CXxhDAYa0qXi8Db+Fpx7muTZX+LkaJ9OPybFvBFII +dyOmpWKkXi1w1416qJ2tfHfSrp+rBzejjKgj9C2WjePqVF4M4UTHFSGj2mU2F8Ru +D5gSFeS8NaBmzwDzo+z7Sat9sBfzDHgqcFyCD4R3JZ0TqlB1gfWIlvDVm4kKZZ0W +JdNnHBfFVj5py1lJDlbeLm/t0Vw/l1KVcYPDstQOAQ+R1H5IZ2euzkOVnDR+2fIt +2zyfwXk3jiN94+5EHkgTGxBkOEUCFz9aP6t3uc63QuM0lDXuObdlQvFhiLqLwjSS +bTSb5AOA9PN4WCtoDxTLrQdyqp9qSvkmFAI3awFe4lYWfRcjfsKXUrwztWPvR+nv +hzO3Jd1JvMbLeIlZ6ej3e8x+5FaqT5mH8DZKWRDYk1gtRV39OmuG69eZMiWJ16BI +XnjlxWCBjiVpN8IF1V6ff86q3Cnsin85T97iqXGm5uCLrNZ53RsDa6pHoyp0Lk5+ +ZiCD6aqVd2cu6yjFLQ+lSH6sIeD5/I+Kdqz/6oH0FyFQWgjApsla/tCl/NhYHqpb +1K3GyoQ5DacSvfhG6xoyC7RODQWYfwCK/4mv/ANf+qkg8o9Rt0tlO/2Im96jlvmi +HFYNlbjD9dIcjMdKgMGKADvi/qCkfJMFK3/T2vsrtrVdnYebSjd0m2Wu6bGU6bJK +w9yJ2anQoHNj0qZzWneMpSV6DBj7ixEyHI1AnGRJjNYpyhtYkmr+A94d0Tr6tDkC +wOY1yy+4HdCvmmYxvxFihu20whktPWrXf7ZwwZUqhPB7fIaTFzg7N7AkK9zInpK4 +ir0n5dVU7Zafc0YGroGph3T+MMq9ihdT+UcZNy1XJugLidNWSKV1FAfR2arRduQi +Zl6MGXjERsKACQ77ENglvhur9nnfju/VJr/B64cw/9t2NdGnTtNQsFcAZZ/LE0d0 +vJzIQzr1+tzdGSEap1bni9pvlfi+pygpLBAFJea9y1wLNBDd7pTdwskuRVSOMff6 +iJKuoeZecoW1SQe/nigeHdCzjl76+aPY+RAwsIHw5G6gx2ylhmh7M+A8HbmhxNZC +4cv7u79LDG3FyRcbrBX+5hMk5+UdlyS9GE93SEFRwc5hJHTkCFoP/74Vj+mD2r3v +JeBT+NyuaL82TJfsefApvuoZ3ddOK34MM2JxsZHuoY4udeOvTj9SdoC5plqcAzTZ +ry9WpMQLn1ET+ec/sNJCOLjkky4c9XxNeriM2poDM1GwtKJ9xubAkZf8ArRSbtP0 +eBkUjUJTUGEC+7iXiYNHfgscd8ut6hxjzyVk45d6Mu9iExbp7B8gyNasZVsVBszV +v76OsOusChcLI9jfMs21M7Ld+Y/VSaxSEgkiDusYD58i45V+1qbw49UYR2eLtovu +7Ts7pS22XPOJWHuoibPWOi4mpiqtJfZ6F74R7fmwTFBPvpRImN2hIX3dL9E78DK+ +iYAmObEA477iAiH+HnMpjgYVK/CTx7/19haVeruOQ7gXRIdLur9sdo71eahV3d29 +yA6lWyXVKqYF3z03IUvjnSMXCd7o/J5payNOfpgXMa4XukMhFzrVIhPiB8iVtYTw +NTFsokoJNbc9WUJVQ4wygdUyxeDL1kugoMHEI49BrSRRd/CgOZ/6SocB+VcRPN1H +eHCw9QuaKrAmCWcT6bt9qOx9j1cbB3BSO7P1XZVFIJ/SY+vNr1RgGFwd/2mJujDl +Kya3aN2o0pn4nAd45PdNszlCFiYdGeY8C51Dyh6PYremVDlMh5uy/yf2RVNSq6Wi +hoPvrMfXnRRb2vTactLkyQarsxlUE7cR0jI4v+Q9sX1NozS/5me47dd3NIebvGNj +Kj56YVpdwTCcaLUvqOmJwa+QVS44VtdWkGFEcZ2VJ//kIzAMYvQJzGtcNjdWQ7rL +AAPLdUSP3ykm3/WMXhDsSjwiQXd4KvLx0VXjgqyAY01Glfl2OSGSbJKA7RB9QNHg +PahNfWKupPzU3l+hrOdbHfhIRSGb8x18adPi+N635T3bHp6xbDyhvZu3Dn7FIt6l +q02BiuexMNDBGKChn3MPfHGHXPVMUi4zd3j0CHwWoHkI+WoDHcYXp+enQYqjl6R+ +6jrOduWwauGF3QGF7Chr/RxKDF1GrC+G+A3Fk6nui4Ay2+Q/9NbqvDSyOJTMByBB +BxC/i9Z1zhpu0dOmBooDVlLMQnCSo6ya9g/6ZWi5kq8yA1bedu80omBLBmRcY+QQ +wrBb8ynJC2e1TiEqCoNpunbf4EAcPFNZZQARV3XzXbRiA0WWz3ifT/QRY6UrnyAv +nhWdJVImg06pVw7wFCOVMnJ2MY/rByxnGVzkfB6LGpJQDjxTdlyEImA+YD8lM+Su +IPpeaTgPNyukOQ8rLXd1HHb0u1deO3DouVW06s72uialywFerIsyEHxyJluYmacA +Y14zu648w8Tzgtd4I34hrIOeORYjUXA/GDrr+jNRbAJntKNmAvbIHwBy3XnQhbYC +T1Z5NbyzfoUKsTf+UuFIUN9x6JuAsf878qBYaXrcpBGq+RHQSfxYdA2NBCjDoWdj +sDYD7v09bros6I0whN0hUgc4ebUjlFe5/aBYHCJeuqw2p1m1wQlzYZ58YJav9jYM +XrsUFhOLTWwU8FgCBLdqxzVqnr4pWmQ7VcOMQM+0wXNlvd9ih2Ads8kuO6ghV0+0 +fjKaGXQ4dpGZ2ADDhakMB7wvu02xCk+Vib3dqk2iDxlIsoG0vs9x94cTqihr/iIE +VjbqNZebpKlcpQI= -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /locales/es.po @@ -36430,206 +36427,202 @@ A/XSwAj/PiGIIPIO -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /locales/fr.po -Size: 43238 +Size: 42597 -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 -cL0n2zaaPeF/NRxPlg6nIs/VbOGAcqrsVdOFZ8r3GfquA8Ie3PSmi5HKOoMiLN2a -AnV2s9X3ZTmE4hLu+k0+b3Wcr+61HqPmNfONrlqGneMmwT/125iI9L67BbC2f0gp -+oKxXqCTDcshtqIHgp8XbJIx2FWOjBP4Sgd3ljN8i3QFS4DcGHq5W2x/QysJjQtg -0YUMT455fmZzdi+krU/eK2sp9G4OSuuqMKErU3qqwV/sTvrRku5ZEGMMDcKr02ds -YoHFzzYJ661H854Wn700wYPj8VOPui1X5225y85r+HDSguNrYxjoBb9NxlvW3tYR -xgn6sSiirFllLA6FGXO7YaOOYIftn2vrYUIkNlxHO+28DKIzfHykvQG/Ipu1le+8 -yb/a8Or4rjP47X72An0VLZtOwGhHoB/8eU5GHGcT4X3o/TP9EQfAvnCPF3Mt92+f -F+9XlW7OtS4c97m97acNDwee/cVvz7I+ba97+5cDvwLDNodRfyXR/rYfwTUFjx/S -rjcYzPL2MkOIktv9ETqA55lk1x/CbDqWbjRgEFRoPGRtO7yplxN83e6cRKtPFHGF -Z+wNt0rkJj0RV0ZlwQOA4ZANQTvDtI2ltieDIaVQ/JZS9q5iI5Xqq36kvr+ARSqD -/pWfQZRHambswoJEGFZx+YieG8MzPNwwiRyO4zroAMH7S/aK+B4bH6ik95mh9TIp -QzRB0wgBW6M6tvPpDvjcSjSnKtsbzHqNgeW6q3lctIO0PB4Fl4+/qKats+WPa37S -GNw2pkr0tbZ8V4oWJu8eVyj6uQf7b4M6GrsfXLR1ujA7hjZQBQ0O080Wps8cKWZF -NXGYl0YZS1kNk4Pn8ltwLBAkvmgxApzSd29my+9rZ7rspTfHTmNFyAUxM38Vh4Yi -qecpN+uBL2uyIQbOGMKuFWTebsRL7Hy1MDEcYLyOaOjUAAzagonhV+0yxU7A2mW7 -oqFXJ9jyBPXt769FdLoWlYm9hIZpJY5T/Zm+xtmNqru68SOtH1n8qeSHx70/NZOb -P5aYyZVYiGWj8lPjDjmff1OXUranHRfktNYV+mO0u3IaLOODZutNPDMiKfKPannA -yqWqWfvl4bA2+CxwNsK8TnZry2RnaZKC1wdil7QqiDGg8Iy6wHCgmYflL1753fyl -apRp0mIdVspgO9Tg60ZJ+sZI5XxiBcC2qoOE97bpXJaQozwLapvaELYm1FmC/YbU -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 -P5t2w9tViYBWkI5lwSYeZ8b7aVmA7a6Rdg6vfBmpZfzChy+qjeVKrb9wtXQcyMEx -/JmHlmCp+ppej+vEsmvQrgd0Sbl2nHcIC2JkNubVYJ0tfcU58b8chiv4oFBCI4r/ -RUdefvqtcPHJ6xTpZVb51A2SmtfYNrWJES8NEktW3u50aqZkHUr4L1t6tj/gcYil -V15tFPkToZ5FHXZ6BS+q/yHTusFLL7Lap4DmpfuV+YvXMDV/ef31zbzelJG+4cGH -vVEvkp2X2TUL2m+lksltddKlAHri6HhRYgf6az71KRutdFY5CrqKDp8D9Xosy2Fu -uyBrmi3ge6V3sTxBVtjnzC9H1Pw0QzHnt67g7fFTaviqPZh+/7CacXsKdgz+kaJa -ficjWf1TeKy2RXv+NzGrXHMyz2W7dadsbx7oXM/+0N7DHpLmD/FF0ipaIN60mxUV -BQ1my5Nod+wrKTpzK511Dm8CxWNlRNE7zjwxvYEWIPXvdC1XpqNrri3gQ3YRVW65 -2dHdwuS33F7PsKBcHa0kK4SZ7w6y7V1PFY8vQ+acXKJv0BLU5Zuz6K3E9fWGshJz -nlbjOdtwL68EbV6B1wX7aHE0TRfwk9eDfqH+YEfmF++k0vHQ7TNH3JpN6OTteKfK -Oee3c/fFhUH3QUMXMs8uYhETM+58o8UbdnDevwUTX/Djs0g8U96XuB+KVYuVXcCH -t+89iP6M97k5SoueZy89ni+hy2pQp251H9N5bvqVSbPCWJzrV2Q6DE+ROdMFquxs -QZa5XM5r+W8fnLg+VvkU/hb35nNf95pVBKaA3yZC1R9jhtaHLJX+69V3vxkeMxdD -T/6ZUjcXpkmczTcj5kSISIy46BQP2HzNM5kSLx1jvpt6+HT7bsByofeZtQx0HxN5 -rQclfqEsnkDMA4iOWEFd4lEcKKF6fv6n8ONpI9+3sjork+x64tPi5WalM8rKGN1k -H9X1BT77/kq0yve3hwKEDL694Zi1JGw1opJfqsxbvsbixppoMs4U64/agfVwo6Wu -VhYJO6PRk7zpD7DZZXyUA2xweQvybq4KZxeWIP+rtkaFST5Wxk+DtDKsZSatXg+S -TI13ZNcMMp5ARORuK0+k+hg7WPwzBaUIp6s7jICUQ+tY/IiN5CdZpCHK8paPcrSl -FRW6KHyoCSwutT37cHO+8IlgOXWCMM+zOxhmw+yx7ISCSuNC1YHHpQshsEhZIdec -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 -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /locales/ja.po @@ -36924,395 +36917,388 @@ sC5yrtufoGL/QHcxuEkMU4jIxyxgSH+bCKQg8gs= -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /locales/ru.po -Size: 52443 +Size: 51497 -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 -vawODIjHlFV9wKc+76R7dynWnKXxEQ== +GyjJjEhOWsNMeCig41p2AszmwQgNLvPwa+wty5Voq8fguLeuSP6f7yIj/R2bnEqt +twzSAvl96k+FfhnWezf19ovioCCMLVS05TCvkpUc1Nf48wjFCbkURHj++UP1CP2O +l/G7xqZiPSAQoHNBOzSvVEc/rHwDEEr7cpkzc1qIKvCK5JmD3wl6mrqfUYrlsexc +4fAbBMaFq///vupnqTAzNsfVL1q7qVyqdMxVpxPuWSYAwotpAkE66k+iNDHdfc65 +D8QjoAWApExSklNMTeWqSKGdsiGdi3Lc+TFUu38TVEBEQE03kzHDNbrGGwIiM3IX +Wxvj9V1tfud44EBFhcPkw//7rBMh857f7CXffKTvW5H7jTH6Dn8zTT859k9l5Ol3 +/aQiwSc26NGnuygYKqAsREyRRxXYlU3onDduXlX9m8TeX7czrL2RDWeQ2tKBh79T +I0ORJs/031lGQucISeY8qp+OrnjdQN73XurTXhSBBdd/1wtQmMnNJSSDw7bg5Sc4 +pbgLCe2ZoGBHBx7xdCC7cJtDh6pa/aBv3ZpdSbiihnavfurvhjqcxjnJZVMtnvBQ +HA5XTHYuXJ/55APq3VdG9yM5Q+ErUyzpN7Zt4SEvEAtXF86KID8jipnYfABkfqLW +j3m6vv/rGWPQKWuIxeScgEZupHoDz98o5zKATuC5KR+sBrW9Vyzab+4IAy6nlAYB +c1I1jmTI6DW0A5ygTMuvQEt8P40ilz9XPxwOmPgLk5XhsL66Y06t8kYNixkDW/P1 +wPpXgREw7rsCKzLzNUktWNJZwYCPGcAEDPH4AZucRJB5HhgsJytAa+umxVpJ+k2R +Y9VOsEIHcU0ZGXgvN3U33wvwmlwTCi/9kaKglWEr1hi9i3FyfCDUmPG5PwRjcyKx +KmiN9dJToYT+oFe0YiYAE7yHt6/1lkYs/oUbPZRNQB3bCgHnk2KRtLYbZYq4AOK7 +hMr648Fkirlrbg5QLFkSud2o8fAk2XGCySaWzTwYavo+9y/eSYYcBNmzdZI8R2I7 +CxLdbYZTOorb9O31ppq8BUvSXtSyjE3aXqYuKhIQh3e41mhjnTjQFfYCeTfQMyie +xguybs+dN/0XfnF7DlArtt1uYA8l/KW0ZXW/69KTjHGfVSnS9Fn+F2VdGiuVS5wW +SeHHGyUKh916WT6+B26sub/PB0chdaWyxXXfnG4c0SOPYnej0naqeLB5NRHAbii0 +vu9IPPTo8rHxGKkZGTy88Ijq5YHJnAaYeKUyMBZLA2Fj8umWENt56Qd64NSGTHgQ +Dx/tSJrP3z+uw9dK/FdY2aipGDI/8jPXEGRY+bPMgk6GVin/xxpGXQh3yrInvDQn +BKDODaCrsr6KAcQXRXtPQ29Fpov5uGY8RHsOasBjYprhiyV77MErToJMNkOepUbf +D4IJwAajpFbJKJxb5MITu0hi7yruUsE92GAkpdkiWfnWJAgR1gl3oha6rFttkrrS +7ooi8zGXT1iXxjaayUsLP9etrKTMm57mUAUR+JM+agPHowN0jKW5vDiRl1291P/I +SsdrFlqKpAfoQbrv+0HNZN/texf3eJ9JKZ2SeK2AaRAE4LCu+8WsiDJPKu1Tdnub +z4nM84q1gWu7G/tC/BMKqTO/Tf5rlT9OC6WPSZmDRta+tEFfYRFcYc9nwcm7inzh +LuP9wVORyiWQ9WBXK28HSKls9stuSYUjEXfOIg1PF47vf5qTDa0XU1YjYKKDsJdX +CKHEFtej56ycqOhUyZU1kEWOL5crDi2dUXnChNmiW/mr9Yhcam3ks0X01bvBGo0H +412IJj/QFD4PeU1UKJKV0yAj20vN4Gq9IZs5cZWwocYv5niKtbux1OCODPS8Z5BS +CtoukUjSsz2S0FTeww32pTxWs5cc9ETCP67MRr5QdjAnVesl0sbysyDDE1gvRyQF +/35IXeoPymYItjSwbGX8PLT7Fv5bcA6vUVkbd7NXBh35KzWi8tYQQodN7Vj0AchW +eBL9/Y7HYqW4wo+Pdr1WCFFlHK+XuBVlO+2wmAi7vLZKmbE3R2O6O5WjJh3dMWnW +wNqnnBegpXTwWvFSzKdrClezdjMOLlah4VcPzNxXc+SMYTDZD/3qq05PFnieemO6 +Aqx0BZTs2K8SHVjqcKAHhHAo1uh72xoLrUV9hAU1ryTICKKR6HKgtBEKJEqXew2j +qYRdFamB1YxgAQ9Izemu07LwLeM7aBW9nn3aW1JaYTBOFwtOb+HbzbgUJc+hrdxv +FgRRr8Xgsi72xjtjHjXZe82xeHcVKe/g07xUTWux2ScmJWeotFn7ulNvCoXkUrqi +rrfJ1/0pFzUZqIV+n+QAOZMe4d3w+xE/gjb7S7Sj/58bvCJsoNIe+7ATPDwrUtdE +gvRvJLgdjlxr24c8QzcStzF+E8Oq3NYFlX7NxYj12SXyff32davhWyfJvAKcOdV7 +KUnp6f0qLg4QrAPU6aoIWIFvf2GE1eLq3lg5wZrmGkSuLa4go7Hh/O1LF/n6TSsM +8Ekr5IL2ZCvgm/fyU3p53Zu14mzuOf5wU8BWrXeoEm27LvOd5fxj/16rPF5GZ/8l +s0dzqTFAjlhGeoPSYLyvlGBL/WQh526lbWF8FP6iFQxlU9pwHONTAHROCAb6bi1t ++m4+IrzOk7SG98DOtXNYyGcuieFvw53XyELI3j4rlpVexD9HcdFGdQqR5raeXKdo +i0DDkp+liyToUkKeiKE7yL8AG/vDkIcL+5MssF2osNw4wTcTAZL7pmKZuPg+DlG9 +Ily4SpVs9Y6lqjtBMBYtUtmCoECQ9ix0/0iEoT2p3WQeFaYT7UxmuhUgZu4soCnx +BtULeiZYjtgaNXWr+XTuKQvTvZzWJWei+RIRKRYECS2BBkjMVQqCqeiG9o1lGLgA +NG7GcLffQUNZqN+mcZku9H/UuTNG6Y3lU/3TGSQUJbriElUBf8gGSZD/rmtJUg63 +qW8vfajZj93xjD3IMrNNmRcWEh0/NLzrbOzHvmuflAuPDPsIxaOeBGCnQtjO67gX +J2kaXdknl3dAXLvvXRbLu15fKPUnH8EMaPtbrd1m3/sjdbg65o0zyzcdXVRQ0ntE +ryY8ZJKeVzDEgTSx7/uRSbJBKtJeGNKwq9r1xrSWFzpDuBGNe6LHloQoUxtBkGJ1 +R6JBIqYarSnOCvI7FcPNbHlWt/fXL0+XSWNSU3F0pO2nIdQR1hwWiQKGiKzGGXQp +U6tYzWJ+ovsWu0dzVGzRrI9+KsSqQN6Ax2DcrChr0Xw3eRSeCoLNaQwZTVjNiCJW +52o4lHLgXa2Qjtda/uuMOBn414QyZgeZ/mTwuSRTVmEoYdi88dA+EQFAk8OIaQ75 +IJ1HqBMSF3gja4ZIiinUwUbQ+SqXB/G9VyCOBR/v0ZrbXeb9KtHOgSMziI4YuopK +kbTe8s+G6pXMqJ2VwqumA0HvvC+42MDTMOdyyx7x6rIK8Q/3onHRVZwzFlB10qHG ++Z7Ru9ze/u1LlCwHvx0DH82RH1bPuLKCwGJt+ei227Ni0nWHz+6o+gDHFzMjlPaJ +AsW9etwl8qiCDlLazLAm+e1fgJFox5jtIKIvvJBZ/CKfEZwVMsS5Cr55F7Sf5WcM +fUqS3aXOBA8qL50V7j0WsfJv3MymwLP9k80XOU/yK/QdBl/kG3kRLemUUO+Ji7Ho +km5tKwOvY5w1U41oPlIai+BHc0if5FUM2t1SjcgzJEfujYWZZCbm671NsQxwa9xV +43uDq59UMZ3fH+Vrlz/MHwweodRboxDarVqXG+Td8G3z4xgJMftz+smQNiRnidWR +X0fiWCrmlIhC40E8KQznwU3PExIpYj9E4lgASevK39mOyKB5hbFRzTAzgStJFDbD +7Tgq1weycwhHB9K6+ViHEhHhyIEVaJqMxIevq3d3WPA80N/AXWqgaJzjBRe6QoFz +cY31V3tyN56dRa7R4cryeEVWiSd2NO19yw7lC0ur9WsppFGylvLX1kKU5mKRP/fG +YI7WwKO9DnxLCTwsodo+pC8/DxrajWbap4OpuhdhdF1lUmbUdct7sFRA7sIw+GbX +PH+DEaG7AUyEsFChRFvd4wk6hqs64mtfpZWtbfRAmSSyHhpkxpwjyxCpvmMmzDs9 +JDANo4HMw79EJboOLq33nTge32cg4JIQq1lwV5bRo8vyvuu9nhnSyrfJ/ZPEwUz9 +u/+CFTF4Cexd6MY8OfXZDe2BFD2ffjrqsgJd9URqQrvOw48nDSaoFhHcSbmBEW5g +HPJvFZ999MK7xSCrFV254gOj8JCBvCGsrT7f9t9WJ/yoLvr3a7lGtLHsoqS71F6p +1pnLsgHDhK/SHoVAY1TKUSZCvCJ/X5zfW4vG2UBBD158EluHHkRZ5r7A6LAz8WaJ +Zvv3YlqClvPon2d3s9BO/TiaLtMm69JxbdFPhtUFOy1+SUKxQSw0GgnLEH/PCXE2 +0vRn2y4e0M/zXOicQqtDoB+H9kDih22P+Fyaf6TVA5mLyHbOb2MdPNiaw9DiJOkQ +qvFGphMBfwzgT+stow7WE7QIchxSUMcBCl+e5O118M2WBDOlm2sxi/kB6SGopFgY +nZQs5aAyVxObUx7foArqtYlews4U0cQpxDHcKIy5q/Xq1KE5s0UZe9lWno23PDQL +4LzyCJLXBwesSxD1zt5rmC+aG9Amqg6uiUzJm8zNKUrs88+cdqVW0HELKanvBR7R +XFnIx14t1LvykySKsy40X8kLPQvcZH5Pa+kqmOavMPv72+0gSp3t1atrjf9EGxVm +5or9E4zfPvtJuaeNJjfMyuwGo9G9qs5GrNjNOVdvvtQyfcK3IRI1XcbmKcW+E0v+ +cNuf7Wewd8/+SQL3dlj2XtePAlFsG4+PX7vy9zKA8LRJqfUFh8f9h/jxwQ665xkd +QIwU5lmuOb7C50oVRGUJ3J6u2x/nYIWWQ/BGp1b1SBsUGVLB4qgvT6ZJpAMDoAvg +/CNTJ8QEGUR/lD2E77TDwj+8lMvey6bgdkrMjATzXxqXTrwjENsvTw682GXeBdqs +BYpaHdQ12T02xppHzfpwZ0L9Hk5O3WWNe1THGCG64opY+81J/cJqWc8XGw+xsbub +anF9ZQAKflFb7kI32qPO8kcIlX3zbkohGF7bavbbad3DiIgv4poanvyIFhrlQu2K +HV1417iJkUSwg+do1TJ7KbiCoOurVUWsUvZCYlDCUBH6BcOzB1vnplCK0Fdfynqm ++PwUhyRK84KXVShr3FCEcAm1PNn0kt+r4cXLSzZPVVA0/hlHRf7ghMtPTXB1frDS +d+bhsWasoiXrK/SPOUUPI0ycrQycwJ9D9CUh7PtOBGUpi3iMHCy11b6Ux3o46PNp +2kH1gFW5KMqYWR1ErBVL5T06qIk8ThsmSAhA0pAW6N5jYJWUrU9vj+wuoxo6mMTS +4Jj8co33QHSmEE9+uuEWlfTobpraKdtwm3shSYvOL7p9vrax47J9IKkX09p0t0Mr +fGGnznqJ9tpIdkNZLp1/xdKwQS0F5yfruNKmc7vLe6IbwFrrrC/rIItyRqbGViS7 +wBuCctBOPjDDm7kcSlFTioXJZcSI47QdA2QzOzF9L4GMN6J4VqsWv4SDrICJ+SH8 +Np9XbDIJKLCuilJYJyfPpBwGAnEYcjtRC55kqXhnnwEpMBzhbOEGdRqDCZLG3YOO +HO/mxGbFmRsVqWHkAMHK68AkT9v1sQwwTlht4CHB5RUcopVjYHc5oZr8HEAy/7Gk +M9qSlLiZr0NUJI/YGcmRdnJYr2IQHFwcB9b8XnjChdYUFO1lYZJtYGK0+XVFCPZ6 +Tp6RA5wVoXdJDp21hYBXdZlpcBp7k40WKxIgMGqqyW1sJje3P2dkzgcwAl3nObTj +X9GfZrtxivT0Ze5b5WCuy++7zgrueXN6sgFkO49G0Z9aCmX0U8w+y2nIOt5Aienp +Wci6rYStg/fws8kUONHiTppE4AzdPe9cI/jl6twIPuSWguTthf/suvlsYERZICdf +vnUm6nZBJxxGKaDkD7otsn7Eazr5zjibzQzkP3dBuYLelqzjLutkUw8KcO7mJEAc +EsD6Jg4xR4XX6aQ29Irmz6LaK7AnFnIXddHYGefzKzZHmcnGVJIUT7ck8zGrAn5O +MK2IX3RXJ5a3Zw4cRLqapK974ga9SnjrAuCaZMb/cdxeN2wvaWWOAol8UUdv4plo +EhGolrC3WfikwUy/7L4iH9kXinPnZkBD49aqSS+slQF7fV/ATdFXJxLhXKqXcYDM +lJipd4b3VFbZhtwct9k6zuFzz+qbw2jG+eSAK1UfUNC6TwMHh9eoRAYD6G7+KFu8 +Sfi8etSUIVg24MXVif9FNvBlRSh0ciI2gUN2/1nzCxGu5nwqeK1uhbp23sfOjJlw +H67JyOZ8eaVQQvB7apmqjBHJ1eSUjX01/961MqT8rcJCNF9Lghqjm2gUQOrQu0sl +wWBPlwt7gaQXQCdcHX6WK/HybWOUWthp597iJZf0miEEqdduhwusH1tEAPA0Tv/L +wgFOA6AjGJj7Ya3I7OhUYIoZDgJkpi0mzK03gLuPmstGdfvKF13bnB8zxF0HNjS5 +rHLdp2uzOgXBuzpdwEP3B5SePuKhJC6mcygQfnv1fmNZhbBkypADv/ICRSQpSBvB +rON7dE2XXohKu/Q7ITWK9c3Q8TDxZGP4n0dVoxm62RcIKjkiptCtWvgt6GIJ7nrO +/V3y5r3XWPZ2KZzBs2mUsjQmNuAcsA7A7ac8/+f7NntCGuMgzIqrDRRTtsdPCvrK +ImBNeP+zuwdycKK9hXMwSPB4otku6m61PgS/uPpWORMukW/rwfC0Pte0opnRoabp +wbjxYZM/LRS/fQOmbdNrDv8r8oaRC6BmZBBAb+l2DruOGQiVQA+wyoZrUos4iasp +uZhK0T7f0ppEBDJc2l1wsaruFSOWdrA8e3bBRZukm7iXSOJPATbCwhpUKtzNiNvi +PoZzf1ZAlu/FJBSluKlJS5ahRFdi+i+2D9ZF1ZvE0agZHkyCqv+8k/P4POWvroXF +3GgrL+2NxWRW9vVbWWgcErgFRjnh7ksreQuiglJWrPdsteJ0lZXtZppxw+LBtZyZ +6umhWkZAgZsgqB2lfY2cENY+mtoWxX5xhK9dgktcFVxeGqGd/2IMCA1EOD7Qp+Vo +twotcXswkRPr+Gdx5xaTaGkxM0m2sc7dIW94ZV3VlJ76sfq4BpPca7rNNiqPl7lF +T7EYpSq40GYbJaimNrqBhDi0MEhl/X/RlLVyR0fdzUO24hnLlqyxpMqBwGa1uGY2 +YAhNfUWsu/Wu1bQvldx4QcyhBjE+I8JQedaWdEtkI76npmG8H/GavXtdSMkWBZny +QNEXukuXQifJ4y7x41uwxWeMlwr10m5IckAq1HHKPSRrG6bbOpJGIhRxytMTP3tA +rWEYgu7fnDsNV4XDibmG2WzPPGSF55Rulcc4BmjSJdP+m23srZxnshktUMrjfY0Z +g43HKrZQDOfcwESXgDKKpX37/OVkr/LeZpag179f6eTw5cB7wdBQou+8aeOXmz+N +E8VT7EBCT7GI/GjTnsg5/qVI1VBQ0mByHPzMFblfglnVQ3KYxSRDywq2mPkuhIuY +3a2V1V7fhre9YP7lUZRneUSlvk0CeNy4sd+fXI58B+UmVbhXNvaWNlX7zC+iIr2k +jWL4SGHtyknxdOIVDNssjby8cAKdHYRWDqJim/VqazK+t96P48xs1Caw41pyYjwv +r22b4V4wi+iC7/8j0XHw1k7NuhwwzO0iXf832/JWHsL+9n5/u1EleL7dztdBFsCx +5nxivqh00VyRiQlLcM0bRdg/U1LPPlAdXtwPhQ8OWUoZUTai8bCdnTxG5R2xPZJp +qw5LFdNODTTkznN/1sYC+szxSckNLZh8Qs3HvwUVwPv/k5czxxf1lJB7OJTL7/by +jNh5PSQjKNt0kjFQNMC7wPXz90hAkCEfI2GRKVZLkl6n8Ah4vQuM4IYte5Ka3WB/ +qTV5cEd5zjfmsi8FcBthUbxg8U2AgLU826M9DF6iZxXAyxF1KaALkVy48YX9igcY ++meUBXFMCjhZwi51GJZDzocjoeCtguZ9EwVA8dNMXozt3l2yXn8rvKScGJY7VTzC +LbtetKcAfxk+BCHrwe7A27NHOfFDGxw1nvQJ94Wx4BY8/t4zx7xnJHe50CW97xaa +HHKM40ajzNuugeu1BLHJYkrt8p0ngHKe+hAhBnZpghDKfzpx2rfUwa9TXZwHTUW/ +wV6v0bTCz+CXZNGaG+wmY7SWI1R28QGZ1tLisRF7mQqnNRCqjLVMC6lmqWon0t59 +mmf5akpnacHWVOWrLOnezHYa56e9523PGXa1/z5ksrPmJrgM0MQQjCEC1LXCMGyG +C7uThe2m+7AeMe1w0Bpensu6HOFkaaNzWjQnjwsHhPTJGzkUXC2JIXhfGDR6da5k +BJiRBTQiYAZqIG1uOeqJ+8K+YJhrprCymXC7jck29wv5a9u7A5h0oAHH40OEPu6l +OIBweG1BRKTJOMeZUsu3ggC01DlhBhWjefX/gCCGQ59NQCzvyEdrrL594VjfyxOP +bmzI6QpIrK66KqACa+m5DQO5O7xmG8xrhMfyWfs0BC/hKdwQP2mjmufeLJ55ErGU +YgpV+FLNxs+CiDYnv2/blS1wkxPLoqXMDY578RzNJgaPhQ+hcSIKNXEPZ5INcjHF +GWJMldwXe9z1NkIEDI9NZQ6sA7nW71egfoXNHcvA3EwGQnOZK/k9dR8VkGuX59n4 +lXMCZgAtmG4+7zp2YUGybIpjFFX67JHDmKAAQwTt9SX+158aJXrxgUNZyCFAJALq +6cbjw2qUAJLu+Z8YQaN6aDeIj9ZaxeLzWibXCtAr7YZfs4CZP7Iu7xqZzSmFaTBE +o5nHXsVmcwwTSPDzm/dbjn6Vq77oFsYs8Dn2inzJ10iDhPuUTeUYIVa8eZJVhZHj +iubFNrUwzfwLPsMzLBxTmsUKecXPyAe/Bkw7F7CbX/OS1X/3ngGuL1z+zB1BworP +DUlf2yQYFA822Lm/U9E+8OcJ4Jcd1RvBuQuBZ2vMxcCQIqlODwZmWtAS02XaNvTj +clb2vBcXDJe5XAQqG9BxsiMAXbUOGue+JV/x4kfxzshSjt5NSdk89SOTvNrJ/29u +p6N7AWxQOuOUdqV+wnzU0UT3bD0l/o6J/Pf/i2Vn+l+M5n+0vkjOp/5FyOW/xG2g +TXeQyFohu8xLO6tYY5Dvt/qrZPDftdfa8yzc7bg/ZJOYCEBkhAAlIqAaQ8FP35Ut +dvHC/Tr9N+mO2sDRZZnFBKDpKATzvNmOanu0WzWcxYE2CAf5e1HdKQUUHGr7J88J +OW0k+fBfoQNfUIF9Y20uLkrt+d16kR0acLzVnhyL/FnVRIbyIHWFQS+i4xlESZxK +HcWJC9eb2/cloy23CEiw6awFxNH9HoiD23a9yGaBCNCo1XuCnNFYRuxfGy1ktdJi +9Sl/NYICY6oeUfVkmCT4VyWuki7+CjiPQdNflhnEL++JODo9QEvaBJ2L/SyrFwWY +3KrTUVBVdUCKT/RevQdc8EY6KeU7SA+0NvwuZzc97zua981cYPKcN260mtEJqtrW +U5q3ZABB7WqMePKBbpIvQteusMe02oDqV6vfuaY66Q3el5kBMMvomBdt/uV2RgNE +o4gwDvzYlb3bl28vi8glvY2ZR55P3arHZgMBA1jqYWsQSTcPlYv4hehiWhUm+i8k +n3Hx+m8uTVSOXYRWhlJ3a6kEB7/l54ivV+3pIBpOnJ5uazid9inzgh5Q0mvo7sQC +4T4A+qs/IrToDT42B1qXdweXceaaf2fMqLpCcBciadwt/NlWHSL0R6u+oTnh4GqR +3XcS33xu/y6MlxecMBwE5rN6QE21Wo3FZurGbqrFhhCIOSYwjaufbC2UZjHg5ukd +Kdik1a4Sw1Oy4wAAW7aQ/3aIw5F+Zd33ZknG8bt6ltMGpNd5cbcgcr+hklFOtGvf ++uFv63sgLdec0tNOChHU/Qd+tH/EG0/tYxqNoCKgLBp5XW4E0zi6nD5aUCtKB5KZ +vrHDTe4fknXGfzHe5lT96H4/p0p4P2M+mxuvO6kYGhZfQcsTnEurcX4U437GJ9bY +wCRyC6IvTaeExeoSeoog7sXLltPea0jlNkZ7Jk+ERPAJyLTpqR+OT7FmlKHFptN9 +oL/PjHS5XDPr5jDYKb6s1Ws67QWtddoIx3EA7ALI9WMtIbXDf6Pc/uthOZglvwAY +weRRrA5vjyA99laQeX4vM4c8P9kpYUZU7HWfLWKT2C6iFtDyjw5ZwcufFET94s+a +OvXOzxVfwu9RAtdmdkyIbEfQS9yoygiNhKg2hCKjJRxaDDcWX5EklZu5kmTtWf7c +IiLYOogWdhCCbSQ1znFYN5bsBkjU5xTvuuEg/vuBek2Treq0VMda7ui8SaHi9kqw +kjMlwLlwaOJ9vXCc2fRUid7CpBnVMNgSQyIFZpoTa6NP70wlHCiTv8S+g/wpUEtN +1MSb7g4mebgz6N16dBdsO1yLxapra70nm+php95Jkbdav0sOJDy4aA380WV30MHO +uS6fya7eRhGPXlSlE6JgCe/8IFIR46KoLHrjFI/AWrbUtv2mEkgqh5uqjteUCG1n +heUwcgpKpuGNU1OIsGeMOWXHcnA/N9vGkI/9U9it1aUi0x7a6Bz4h1FXRr6qgQw2 +VZUV0ewqL1nbpzkK0HQN60lTblFLJX6tkCZoxO6my73Nd3yIIlCjqVnfJ+Zp96OD +7CzxQmFt/Y/n8M0UfnA4La27iImXNIGy7cmzeQNm1gsbFR9kUAiZhfLjAkm2wwDw +z0SquGc6iy98YWUa3o9ua0ldZ6T2WIUQonueSbHQqw6IsRpAdHgfRaxd26ZzHzuQ +I17viMPalbDz9L4341jguhv5XcYsDrwV/ffF9EJ8GuXTk7iwV0+wC72MEJ0a5+pk +c3CbVu35MqNSXF9xQSG6ULl4LOW/2nC2rq8j6e0+0F98ltKiAmire8NvdtbFcxFn +xNxpEkwzqB472yniWdEi76EEDPDiuXjNsv6n3ktwMOtqracdGQwQsrdHhVt1RYpZ +eSF7dQDwi/++79b3J9p5wUir+qM1Ypmtd+gxjeCCP0fegvaiDQwMVakaQadNvU62 +2XGYlCjT05tjGqD/QheKOBqPcSAeplfB320XBXUslpIV53sP4kJaBcL0iWIJnluu +LVc9qmFOKd9+pksXGoYXhsWnPErUJERTIHBeWSiSgb+4ond9cop0Pq1tYpiPhcT6 +iqj6KSGcxppNjSZ3YnxSs6AjHgL7Dvcpq8CSHkqGur9JKuVwL65FnXZmhAB8oeV/ +IP18QZyGl/2krLCZtfVhWgO77x9YCHEXTO4dJ9+MlTVp+fDlG/lBuWaPw19vMkns +KCt/+UQynBgM9DO9rD1JqHl0wUbGY0Z7LBzjk/Gqirs8BYY9QzPJcwmP83Hj5Ukp +nrCcKM12YCOEcQ3OPDYd1VytGJcpe2jWY6urssHQ5BTxG5eqYtfmPSO39sbQJHEC +BR0GBC/dfAdx6v9KYufXWuoNuNITsRAnt7UjSr+i9FoPf/w34BBeOO6dCZ5/AIMD +I3P2fpURKZdfYlnWgYbtycGA3hcP61O+VzXU61lAxKvJPhKL5BhMrLbUPS/ybehv +guEg3vAJqYK7KOdYNPLwte2mZry7OW68BgfS4hx8PIC078BNmgeFtdv4nRJFnZsz +HCK7Yoe22dJKsol+zYxYRtcZvjCWr3Epj1PSRYUdomG8lS76bXomt7Bv67N6P3eb +7pwBnFEBmQ9ncX4LE8Oy4MvQMscGrFug22bug+d7pT9lNeC7yU1huONTMSXLrntR +uU4SFS8da8qECII2f6JWaZbn1RC5N3HdYcR89JdYvwrLG7qZC7zLzv/gej8oh4fA +zVNzvYQMSQC6xHqTYze1seP2xcDukHAQ5zjuPC124ypdQDwG2j/ygos37PAad/7i +g6StM5gPf+dp+BaAT9bWjGhBfY8sQ4EKhToX4PQDoJegXNkyqbwsDtoJcbmZtyVM +hxqX6FZYIEomYjdL6d6uHZTUSXox9SA9l8C9nhzMR0P/vKFEzlY4LSGQH52Q2bG2 +wUna81cJJ/CFtCVYK+PZDju+ezrxjcy3wWwKPwpIf5uaC2w5yOc3xynuOBFb5Sjq +ElryGcy2v+BIyO2X1z7ZjdM3MY5JYoeIYo4ZA64iEx9uCS8rOlBoqrRI8/JvIjPF +JrbHT+Eq+B93Fonk15dOo5tdiZ+fjNl2zgQby5XnUs9rtQQcTDTHhpzwhicCx+ts +s+Md7cumqyFmXuA20AshadcbEoR+29Krv/cal+hFOjMB5yEB9Q3UnzTjG+iVgxQP +gQSAh/hotQp22/DqcamK2wZ0LVN/sI3VN6HcOLbmtgp+BSDZxZaIsSxWvyP9ZIvo +i1zXkJHPY37SptaACZNm2Xl9myGJDSW4/Qo= -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /locales/vi.po -Size: 45279 +Size: 44497 -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 +04OJbWa5Hp1bTmeqAA== -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /mails/alert_account.mjml From 3d4810c75212f53c7cadbe9e96baacc8c53c55ac Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 10:10:21 +0100 Subject: [PATCH 02/17] test(banners): fail fixture loading through t instead of panicking The shared wire fixtures were decoded by a helper that panicked on a read or unmarshal error, so a broken fixture reported a stack trace rather than the test that wanted it. It takes t now, marks itself a helper and uses require.NoError, as asked in review. --- model/banner/command_io_test.go | 2 +- model/banner/command_test.go | 52 ++++++++++++++++----------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go index ed96fbd9cd8..0382de1384b 100644 --- a/model/banner/command_io_test.go +++ b/model/banner/command_io_test.go @@ -35,7 +35,7 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { members, err := lifecycle.ListOrgInstances(org) require.NoError(t, err) require.Len(t, members, 3) - cmd := fixture("organization") + cmd := fixture(t, "organization") cmd.Domain = org failPath := "/" + couchdb.EscapeCouchdbName(members[1].DBPrefix()+"/"+consts.Banners) + "/banner-billing" client := config.CouchClient() diff --git a/model/banner/command_test.go b/model/banner/command_test.go index 246ec9ff6af..0b458440aaf 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -23,25 +23,25 @@ const decidedAt = 1788944400 // fixture decodes one of the shared wire fixtures, which are what the backend // publisher is developed against. A field this package stops reading, or a // field it starts requiring, breaks here rather than in production. -func fixture(name string) Command { +func fixture(t *testing.T, name string) Command { + t.Helper() raw, err := os.ReadFile("testdata/" + name + ".json") - if err != nil { - panic(err) - } + require.NoError(t, err) var cmd Command - if err := json.Unmarshal(raw, &cmd); err != nil { - panic(err) - } + require.NoError(t, json.Unmarshal(raw, &cmd)) return cmd } // valid is a command every field of which passes, so a case can break exactly // one thing and name what it broke. -func valid() Command { return fixture("materialize") } +func valid(t *testing.T) Command { + t.Helper() + return fixture(t, "materialize") +} func TestFixturesAreTheContract(t *testing.T) { t.Run("a materialize carries its decision and every locale of it", func(t *testing.T) { - cmd := fixture("materialize") + cmd := fixture(t, "materialize") assert.Equal(t, "alice.twake.app", cmd.WorkplaceFqdn) assert.Empty(t, cmd.Domain) @@ -64,7 +64,7 @@ func TestFixturesAreTheContract(t *testing.T) { }) t.Run("a clear carries no wording", func(t *testing.T) { - cmd := fixture("clear") + cmd := fixture(t, "clear") cmd.Clear = true assert.Empty(t, cmd.BannerID) @@ -74,7 +74,7 @@ func TestFixturesAreTheContract(t *testing.T) { }) t.Run("an organization is addressed by its domain", func(t *testing.T) { - cmd := fixture("organization") + cmd := fixture(t, "organization") assert.Equal(t, "acme.example", cmd.Domain) assert.Empty(t, cmd.WorkplaceFqdn) @@ -168,7 +168,7 @@ func TestValidateRejections(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - cmd := valid() + cmd := valid(t) tc.break_(&cmd) err := cmd.validate() require.Error(t, err) @@ -178,12 +178,12 @@ func TestValidateRejections(t *testing.T) { } t.Run("a complete command is accepted", func(t *testing.T) { - assert.NoError(t, valid().validate()) + assert.NoError(t, valid(t).validate()) }) t.Run("a clear is still addressed and ordered", func(t *testing.T) { clear := func() Command { - cmd := fixture("clear") + cmd := fixture(t, "clear") cmd.Clear = true return cmd } @@ -212,7 +212,7 @@ func TestCommandDocumentShape(t *testing.T) { at := time.Unix(decidedAt, 0).UTC() t.Run("every field of the contract", func(t *testing.T) { - b := valid().banner("en") + b := valid(t).banner("en") require.NotNil(t, b) assert.Equal(t, "billing.grace.cycle-a.attempt-2", b.BannerID) assert.Equal(t, CategoryBilling, b.Category) @@ -230,7 +230,7 @@ func TestCommandDocumentShape(t *testing.T) { }) t.Run("a window the backend left out starts when it decided", func(t *testing.T) { - cmd := valid() + cmd := valid(t) cmd.StartsAt, cmd.EndsAt = nil, nil b := cmd.banner("en") require.NotNil(t, b) @@ -240,7 +240,7 @@ func TestCommandDocumentShape(t *testing.T) { }) t.Run("a clear produces no document", func(t *testing.T) { - cmd := valid() + cmd := valid(t) cmd.Clear = true assert.Nil(t, cmd.banner("en")) }) @@ -248,7 +248,7 @@ func TestCommandDocumentShape(t *testing.T) { func TestLocaleIsPickedForTheWholeBanner(t *testing.T) { t.Run("the instance locale when the backend sent all of it", func(t *testing.T) { - b := valid().banner("fr") + b := valid(t).banner("fr") require.NotNil(t, b) assert.Equal(t, "Échec du paiement", b.Title) assert.Contains(t, b.Text, "Nous n'avons pas pu") @@ -257,21 +257,21 @@ func TestLocaleIsPickedForTheWholeBanner(t *testing.T) { }) t.Run("the fallback locale when the backend sent none of it", func(t *testing.T) { - b := valid().banner("de") + b := valid(t).banner("de") require.NotNil(t, b) assert.Equal(t, "Payment failed", b.Title) assert.Equal(t, "en", b.Lang, "lang names the language the user actually reads") }) t.Run("an instance with no locale reads the fallback", func(t *testing.T) { - b := valid().banner("") + b := valid(t).banner("") require.NotNil(t, b) assert.Equal(t, "en", b.Lang) }) t.Run("a language the stack has no catalog for is still the backend's to send", func(t *testing.T) { require.NotContains(t, consts.SupportedLocales, "ru") - cmd := valid() + cmd := valid(t) cmd.Text["ru"] = "Мы не смогли списать средства с вашей карты." cmd.Title["ru"] = "Платёж не прошёл" cmd.CTA.Label["ru"] = "Обновить способ оплаты" @@ -290,7 +290,7 @@ func TestLocaleIsPickedForTheWholeBanner(t *testing.T) { func(c *Command) { delete(c.CTA.Label, "fr") }, func(c *Command) { delete(c.SecondaryCTA.Label, "fr") }, } { - cmd := valid() + cmd := valid(t) missing(&cmd) b := cmd.banner("fr") require.NotNil(t, b) @@ -351,7 +351,7 @@ func newInstance(t *testing.T, contextName, locale, orgDomain string) *instance. // keeps the fixture's timestamp, so only the revision orders them. func materialize(t *testing.T, inst *instance.Instance, revision int64) Command { t.Helper() - cmd := fixture("materialize") + cmd := fixture(t, "materialize") cmd.WorkplaceFqdn = inst.Domain cmd.Revision = revision return cmd @@ -359,7 +359,7 @@ func materialize(t *testing.T, inst *instance.Instance, revision int64) Command func clearCommand(t *testing.T, inst *instance.Instance, revision int64) Command { t.Helper() - cmd := fixture("clear") + cmd := fixture(t, "clear") cmd.WorkplaceFqdn = inst.Domain cmd.Revision = revision cmd.Clear = true @@ -512,7 +512,7 @@ func TestApplyCommand(t *testing.T) { }) t.Run("an unknown workplace is retried, not rejected", func(t *testing.T) { - cmd := valid() + cmd := valid(t) cmd.WorkplaceFqdn = fmt.Sprintf("missing-%d.example", time.Now().UnixNano()) err := ApplyCommand(cmd) @@ -574,7 +574,7 @@ func TestApplyCommandToAnOrganization(t *testing.T) { orgCommand := func(t *testing.T, orgDomain string, revision int64) Command { t.Helper() - cmd := fixture("organization") + cmd := fixture(t, "organization") cmd.Domain = orgDomain cmd.Revision = revision return cmd From 36d95f94b292cc0d68bd78e12de697d88443f1eb Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 10:12:20 +0100 Subject: [PATCH 03/17] fix(banners): re-localize commanded banners on a language change A commanded banner was stored in one language and never revisited, so an instance that switched language ended up with the quota banner reworded and the billing banner still in the old one, side by side, as the review reported. The private io.cozy.banners.commands record now keeps the accepted command whole, with every locale the backend sent, and the existing RefreshBanners hook picks a language again from it. Reconstructing severity, surface, occurrence id and the action URLs from the public document instead would mean trusting fields an application can rewrite, which is the reason that doctype is private. Refresh takes the same instance lock a command takes, so it cannot race a newer command into restoring what that command replaced. It moves no revision, since re-picking a language is not a decision, and it keeps a dismissal because the occurrence id is unchanged. A cleared category, a record written before the stack retained the wording, and a category the context no longer accepts are all left alone. Re-localizing rewrites a banner rather than restoring one: a category whose document is gone stays gone until the next command. Recreating it from the retained command alone would move a scheduled banner to the decision time of whichever command wrote last. --- docs/banners.md | 13 ++++-- model/banner/command.go | 30 +++++++++++- model/banner/command_test.go | 88 ++++++++++++++++++++++++++++++++++-- model/banner/trigger.go | 76 ++++++++++++++++++++++++++++++- 4 files changed, 195 insertions(+), 12 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 806fc01bcee..447b0dbba42 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -116,10 +116,13 @@ string the document needs exists in it, and `en` otherwise. `lang` names the language the user actually reads. Falling back field by field would put a French sentence above an English button. -The stack keeps no copy of the locales it did not use, so changing an -instance's language leaves a commanded banner in the language it was -materialized in until the backend publishes its next command. Only the banners -the stack writes itself are reworded on a language change. +The stack keeps every locale the command carried, on the private +`io.cozy.banners.commands` document, so changing an instance's language picks +one again without the backend publishing anything. The revision, the wording +and the decision time are unchanged: only the language moves. Re-localizing +rewrites a banner rather than restoring one, so a category whose document is +gone stays gone until the next command. A record written before the stack +retained the wording has nothing to pick from, and stays as it is too. The languages available for a commanded banner are the ones the backend sends, not the stack's `consts.SupportedLocales`: the stack renders nothing here, so @@ -193,7 +196,7 @@ recorded for a member whose banner was not written. Bus delivery is at-least-once and unordered, so ordering cannot come from the arrival time, and it cannot come from the visible document either: a clear leaves none behind and an unchanged decision writes none. The stack keeps the -last accepted revision per instance and category in `io.cozy.banners.commands`, +last accepted command per instance and category in `io.cozy.banners.commands`, a separate doctype blocked from public reads and writes, including wildcard application grants and the bulk/replication API, so an application cannot rewrite the ordering record. It is a normal document, so it is included in the diff --git a/model/banner/command.go b/model/banner/command.go index 611194c8767..30cc73315b4 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -174,6 +174,9 @@ func (cmd Command) applyTo(inst *instance.Instance) error { // commandState records the last command accepted for a category, so ordering // survives clears (which leave no public document) and unchanged decisions. +// It keeps the command whole, so the stack can pick the language again when +// the instance changes locale without the backend publishing anything. The +// doctype is blocklisted, so none of this is reachable from an application. type commandState struct { DocID string `json:"_id,omitempty"` DocRev string `json:"_rev,omitempty"` @@ -182,6 +185,11 @@ type commandState struct { Revision int64 `json:"revision"` Clear bool `json:"clear"` EventID string `json:"eventId,omitempty"` + + // Accepted is the command as it arrived, with every locale the backend + // sent. Absent on a clear, and on a record written before the stack + // retained it: only a new command can refresh one of those. + Accepted *Command `json:"accepted,omitempty"` } func (d *commandState) ID() string { return d.DocID } @@ -194,12 +202,32 @@ func (d *commandState) Clone() couchdb.Doc { cloned := *d; return &cloned } var _ couchdb.Doc = &commandState{} func (cmd Command) state() *commandState { - return &commandState{ + state := &commandState{ Category: cmd.Category, Revision: cmd.Revision, Clear: cmd.Clear, EventID: cmd.EventID, } + // A clear has no wording to keep, and its category holds no document to + // re-localize. + if !cmd.Clear { + accepted := cmd + state.Accepted = &accepted + } + return state +} + +// storedCommands returns the state retained for every category of an instance. +func storedCommands(db prefixer.Prefixer) ([]*commandState, error) { + var states []*commandState + err := couchdb.GetAllDocs(db, consts.BannerCommands, nil, &states) + if couchdb.IsNoDatabaseError(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return states, nil } // storedCommand returns the state retained for a category, or nil when none diff --git a/model/banner/command_test.go b/model/banner/command_test.go index 0b458440aaf..2dad0757319 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -373,6 +373,25 @@ func storedBanner(t *testing.T, inst *instance.Instance) *Banner { return stored } +func storedState(t *testing.T, inst *instance.Instance) *commandState { + t.Helper() + stored, err := storedCommand(inst, CategoryBilling) + require.NoError(t, err) + require.NotNil(t, stored) + return stored +} + +// dismiss records a dismissal the way an application does, by writing the +// public document. +func dismiss(t *testing.T, inst *instance.Instance) { + t.Helper() + stored := storedBanner(t, inst) + require.NotNil(t, stored) + at := time.Now().UTC().Truncate(time.Second) + stored.DismissedAt = &at + require.NoError(t, couchdb.UpdateDoc(inst, stored)) +} + func TestApplyCommand(t *testing.T) { config.UseTestFile(t) needCouchDB(t) @@ -485,14 +504,73 @@ func TestApplyCommand(t *testing.T) { require.NotNil(t, stored) assert.Equal(t, "fr", stored.Lang) assert.Equal(t, "Échec du paiement", stored.Title) + }) + + t.Run("a refresh re-localizes a banner but does not restore a deleted one", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + scheduled := materialize(t, inst, 76) + starts := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) + ends := time.Date(2027, 3, 2, 0, 0, 0, 0, time.UTC) + scheduled.StartsAt, scheduled.EndsAt = &starts, &ends + require.NoError(t, ApplyCommand(scheduled)) + + // A later decision on the same occurrence that states no window: the + // scheduled start now lives only on the public document. + reworded := materialize(t, inst, 77) + reworded.StartsAt, reworded.EndsAt = nil, &ends + require.NoError(t, ApplyCommand(reworded)) + require.Equal(t, starts, storedBanner(t, inst).StartsAt.UTC()) + + // An application deletes it, as its write access lets it. + require.NoError(t, couchdb.DeleteDoc(inst, storedBanner(t, inst))) + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) + + assert.Nil(t, storedBanner(t, inst), + "recreating it would move a March 2027 banner to the decision time") + }) + + t.Run("a language change re-picks a locale the backend already sent", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 61))) + require.Equal(t, "fr", storedBanner(t, inst).Lang) + + // The stack keeps every locale the backend sent, so it can pick again + // without the backend publishing anything. + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "en", stored.Lang) + assert.Equal(t, "Payment failed", stored.Title) + assert.Equal(t, int64(61), storedState(t, inst).Revision, "re-localizing is not a decision") + }) + + t.Run("a language change falls back for a locale the backend did not send", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 62))) - // The stack keeps no copy of the other locales, so a language change - // leaves the wording as it was until the backend sends the next - // command. Only the stack's own rules reword themselves. require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "de"})) - stored = storedBanner(t, inst) + + stored := storedBanner(t, inst) require.NotNil(t, stored) - assert.Equal(t, "fr", stored.Lang) + assert.Equal(t, consts.DefaultLocale, stored.Lang) + }) + + t.Run("a language change keeps a dismissal and a cleared category", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 63))) + dismiss(t, inst) + + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "en", stored.Lang) + require.NotNil(t, stored.DismissedAt, "a new language is not a new occurrence") + + require.NoError(t, ApplyCommand(clearCommand(t, inst, 64))) + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "fr"})) + assert.Nil(t, storedBanner(t, inst), "a cleared category stays cleared") }) t.Run("a category the context does not accept is refused", func(t *testing.T) { diff --git a/model/banner/trigger.go b/model/banner/trigger.go index 04c8e874d72..3d0f1fd4234 100644 --- a/model/banner/trigger.go +++ b/model/banner/trigger.go @@ -29,7 +29,12 @@ func init() { // The other half of the quota: the Cloudery moves the limit rather than // the usage, and a downgrade crosses the threshold with nothing written. - lifecycle.RefreshBanners = func(domain string) { refreshQuota(domain, -1) } + // A language change lands here too, and reaches the commanded banners as + // well as the ones the stack words itself. + lifecycle.RefreshBanners = func(domain string) { + refreshQuota(domain, -1) + refreshCommands(domain) + } } // refreshQuota re-evaluates the quota banner of an instance. A negative usage @@ -84,3 +89,72 @@ func refreshQuotaAt(domain string, used int64) error { now := time.Now() return Materialize(inst, CategoryQuota, EvaluateQuota(state, now), now) } + +// refreshCommands re-materializes the commanded banners of an instance from +// the commands the stack retained. It decides nothing: the wording, the +// revision and the decision time are the ones the backend already sent, and +// only the language is picked again. Without this a language change leaves a +// commanded banner in the previous language next to a stack written one in +// the new one. +func refreshCommands(domain string) { + if err := refreshCommandsAt(domain); err != nil { + logger.WithDomain(domain).WithNamespace("banner"). + Warnf("cannot refresh the commanded banners: %s", err) + } +} + +func refreshCommandsAt(domain string) error { + inst, err := lifecycle.GetInstance(domain) + if err != nil { + return err + } + if !inst.HasBannersEnabled() { + return nil + } + + // The lock a command takes, so a refresh cannot race a newer command into + // restoring the banner that command just replaced. + mu := config.Lock().ReadWrite(inst, "banners") + if err := mu.Lock(); err != nil { + return err + } + defer mu.Unlock() + + // ponytail: one _all_docs read per instance patch, including the quota + // patches that share this hook and never need it. Split the hook by reason + // if that read ever shows up. + states, err := storedCommands(inst) + if err != nil { + return err + } + + now := time.Now() + for _, state := range states { + // A cleared category holds no document, and a record from before the + // stack retained the wording has nothing to pick from. + if state.Clear || state.Accepted == nil { + continue + } + // A category the context has stopped accepting is left alone rather + // than rewritten; turning the setting off needs a cleanup either way. + if !inst.AllowsBannerCategory(state.Category) { + continue + } + // Re-localizing rewrites a banner, it does not restore one. The + // retained command carries the wording, not the occurrence's own + // start, which lives on the document an application is allowed to + // delete; recreating from the command alone would move a scheduled + // banner to the decision time of whichever command wrote last. + current, err := Stored(inst, state.Category) + if err != nil { + return err + } + if current == nil { + continue + } + if err := Materialize(inst, state.Category, state.Accepted.banner(inst.Locale), now); err != nil { + return err + } + } + return nil +} From de627cb635cc9ff49e77b87f7b8afe36c31dee88 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 10:14:15 +0100 Subject: [PATCH 04/17] fix(banners): apply both ends of a command's validity window An explicit startsAt was accepted and then discarded whenever a banner already existed with the same bannerId, while endsAt was applied, so a backend moving its window got half the change: a first command running August 2026 followed by one moving both ends into March 2027 left a banner running August 2026 to March 2027, which is neither window and raised no error. A nil StartsAt now means the producer stated no window, and the moment the occurrence began is carried forward. A producer that states one owns it. Both producers had to stop stating a default for that rule to hold. The quota rule no longer assigns StartsAt on every evaluation, and a command no longer defaults it to the decision time, so Materialize fills a nil start from the producer's own decision: the evaluation time for a rule, the command timestamp for a command. Quota's stored documents are unchanged, since the value it used to assign itself is now the value Materialize fills from the same clock reading. Validation cannot see the stored occurrence, so it no longer substitutes the decision time for an omitted start. It judges a window only when the command states both ends, which stops it refusing an end the backend is entitled to move on its own. Carrying a start forward is skipped when it would land after an end the producer just stated, so a shortened window cannot invert. --- docs/banners.md | 9 ++-- model/banner/banner_test.go | 39 +++++++++++++---- model/banner/command.go | 15 +++---- model/banner/command_test.go | 84 +++++++++++++++++++++++++++++++++--- model/banner/materialize.go | 23 ++++++++-- model/banner/quota.go | 4 +- 6 files changed, 141 insertions(+), 33 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 447b0dbba42..88506362d0a 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -100,7 +100,7 @@ fields only. Nonempty presentation fields are rejected: | `cta`, `secondaryCta` | no | `url` must be an absolute `https` URL. A secondary action needs a primary one. | | `dismissible` | no | Defaults to false. A modal with neither a call to action nor a dismissal is made dismissible. | | `priority` | no | 0 to 1000. The stack's own quota banners sit at 50 and 100. | -| `startsAt`, `endsAt` | no | RFC 3339. `startsAt` defaults to the decision time. | +| `startsAt`, `endsAt` | no | RFC 3339. A stated `startsAt` replaces the stored one. Omit it to keep the moment the occurrence began, which is the decision time of the command that opened it. | The document also carries `source.trigger`, which is `banner.command` for everything that arrives this way, and `cozyMetadata.createdByApp`, which stays @@ -145,9 +145,10 @@ command fails the delivery, so the broker redelivers it up to the queue's - `bannerId` matches `^[a-z0-9.-]{1,64}$`. - `severity` is one of `info`, `warning`, `error`. - `surface` is one of `banner`, `modal`. -- `priority` is between 0 and 1000; the effective start (`startsAt`, or - `timestamp` when omitted) is before `endsAt`. Window values must be - representable in RFC3339. +- `priority` is between 0 and 1000. Window values must be representable in + RFC3339, and a command that states both ends has `startsAt` before `endsAt`. + A command that states only `endsAt` is not judged here: its start comes from + the stored occurrence, so moving an end alone is allowed. - `text`, `title` and every label are present in the `en` fallback locale. - a call to action has an absolute `https` URL, and a secondary one has a primary alongside it. diff --git a/model/banner/banner_test.go b/model/banner/banner_test.go index 61aef0b4746..ac0b3f8a099 100644 --- a/model/banner/banner_test.go +++ b/model/banner/banner_test.go @@ -149,11 +149,11 @@ func TestEvaluateQuotaDocumentShape(t *testing.T) { SettingsURL: "https://jdoe-settings.example.org/#/subscription", } - t.Run("the validity window starts when the occurrence does", func(t *testing.T) { + t.Run("the rule states no window, so the occurrence keeps its own start", func(t *testing.T) { b := EvaluateQuota(state, now) require.NotNil(t, b) - require.NotNil(t, b.StartsAt, "startsAt is not one of the fields a client may find missing") - assert.Equal(t, now, *b.StartsAt) + assert.Nil(t, b.StartsAt, "a re-evaluation must not move the moment the occurrence began") + assert.Equal(t, now, b.Source.At, "which is what Materialize starts a first one at") }) t.Run("the text is localized, and lang says which language it is in", func(t *testing.T) { @@ -197,7 +197,7 @@ func TestMergeCarriesTheWindowForward(t *testing.T) { t.Run("the same occurrence keeps the moment it started", func(t *testing.T) { stored := &Banner{DocID: "abc", BannerID: BannerIDQuotaAlmostFull, StartsAt: &began} - fresh := &Banner{BannerID: BannerIDQuotaAlmostFull, StartsAt: &now} + fresh := &Banner{BannerID: BannerIDQuotaAlmostFull} merged := Merge(fresh, stored) @@ -205,14 +205,36 @@ func TestMergeCarriesTheWindowForward(t *testing.T) { assert.Equal(t, began, *merged.StartsAt) }) - t.Run("a new occurrence starts now", func(t *testing.T) { + t.Run("a new occurrence forgets it", func(t *testing.T) { stored := &Banner{DocID: "abc", BannerID: BannerIDQuotaAlmostFull, StartsAt: &began} - fresh := &Banner{BannerID: BannerIDQuotaExceeded, StartsAt: &now} + fresh := &Banner{BannerID: BannerIDQuotaExceeded} + + merged := Merge(fresh, stored) + + assert.Nil(t, merged.StartsAt, "Materialize starts a new occurrence at the decision") + }) + + t.Run("a stated window replaces the stored start", func(t *testing.T) { + moved := now.Add(72 * time.Hour) + ends := moved.Add(24 * time.Hour) + stored := &Banner{DocID: "abc", BannerID: BannerIDQuotaAlmostFull, StartsAt: &began} + fresh := &Banner{BannerID: BannerIDQuotaAlmostFull, StartsAt: &moved, EndsAt: &ends} merged := Merge(fresh, stored) require.NotNil(t, merged.StartsAt) - assert.Equal(t, now, *merged.StartsAt) + assert.Equal(t, moved, *merged.StartsAt, + "a producer moving its own window gets the window it asked for") + }) + + t.Run("an omitted start is not carried into an inverted window", func(t *testing.T) { + ends := began.Add(-24 * time.Hour) + stored := &Banner{DocID: "abc", BannerID: BannerIDQuotaAlmostFull, StartsAt: &began} + fresh := &Banner{BannerID: BannerIDQuotaAlmostFull, EndsAt: &ends} + + merged := Merge(fresh, stored) + + assert.Nil(t, merged.StartsAt, "a start after the end would never display") }) t.Run("merging does not write through to the evaluated banner", func(t *testing.T) { @@ -270,8 +292,7 @@ func TestEvaluateQuotaFillsEveryContractField(t *testing.T) { assert.Equal(t, "fr", b.Lang) assert.True(t, b.Dismissible) assert.Equal(t, 50, b.Priority) - require.NotNil(t, b.StartsAt) - assert.Equal(t, now, *b.StartsAt) + assert.Nil(t, b.StartsAt, "Materialize fills the window a rule states none of") assert.Equal(t, TriggerUsageThreshold, b.Source.Trigger) assert.Equal(t, now, b.Source.At) assert.Nil(t, b.DismissedAt) diff --git a/model/banner/command.go b/model/banner/command.go index 30cc73315b4..8a6de89c7ac 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -267,11 +267,6 @@ func (cmd Command) banner(instanceLocale string) *Banner { EndsAt: cmd.EndsAt, Source: Source{Trigger: TriggerCommand, At: at}, } - // Default to the decision time so a redelivery is identical to the original. - if b.StartsAt == nil { - startsAt := at - b.StartsAt = &startsAt - } return b } @@ -360,10 +355,6 @@ func (cmd Command) validate() error { if cmd.Priority < 0 || cmd.Priority > maxPriority { return fmt.Errorf("%w: priority %d is outside 0..%d", ErrInvalidCommand, cmd.Priority, maxPriority) } - startsAt := time.Unix(cmd.Timestamp, 0).UTC() - if cmd.StartsAt != nil { - startsAt = *cmd.StartsAt - } for _, at := range []*time.Time{cmd.StartsAt, cmd.EndsAt} { if at != nil { if _, err := at.MarshalJSON(); err != nil { @@ -371,7 +362,11 @@ func (cmd Command) validate() error { } } } - if cmd.EndsAt != nil && !startsAt.Before(*cmd.EndsAt) { + // Only a window the command states both ends of can be judged here. When + // the start is left out it comes from the stored occurrence, which this + // side of the lock cannot see, so substituting the decision time would + // refuse an end the backend is entitled to move on its own. + if cmd.StartsAt != nil && cmd.EndsAt != nil && !cmd.StartsAt.Before(*cmd.EndsAt) { return fmt.Errorf("%w: startsAt is not before endsAt", ErrInvalidCommand) } // cozy-client drops a secondary action that has no primary. diff --git a/model/banner/command_test.go b/model/banner/command_test.go index 2dad0757319..745386c7691 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -139,8 +139,6 @@ func TestValidateRejections(t *testing.T) { {"a priority above the range", func(c *Command) { c.Priority = maxPriority + 1 }, "priority"}, {"a window that ends before it starts", func(c *Command) { c.StartsAt, c.EndsAt = c.EndsAt, c.StartsAt }, "startsAt is not before endsAt"}, {"a window with no length", func(c *Command) { c.EndsAt = c.StartsAt }, "startsAt is not before endsAt"}, - {"implicit start after end", func(c *Command) { c.StartsAt = nil }, "startsAt is not before endsAt"}, - {"implicit start equals end", func(c *Command) { c.StartsAt = nil; at := time.Unix(c.Timestamp, 0); c.EndsAt = &at }, "startsAt is not before endsAt"}, {"window outside RFC3339", func(c *Command) { at := time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC); c.EndsAt = &at }, "window must be within"}, {"no text", func(c *Command) { c.Text = nil }, "required in the en fallback locale"}, {"text without the fallback locale", func(c *Command) { delete(c.Text, "en") }, "required in the en fallback locale"}, @@ -177,6 +175,20 @@ func TestValidateRejections(t *testing.T) { }) } + t.Run("an end stated without a start is not judged here", func(t *testing.T) { + // The start comes from the stored occurrence, which validation cannot + // see, so the pair is checked once the merge has resolved it. + for _, name := range []string{"an end before the decision", "an end at the decision"} { + cmd := valid(t) + at := time.Unix(cmd.Timestamp, 0).UTC() + if name == "an end before the decision" { + at = at.Add(-24 * time.Hour) + } + cmd.StartsAt, cmd.EndsAt = nil, &at + assert.NoError(t, cmd.validate(), name) + } + }) + t.Run("a complete command is accepted", func(t *testing.T) { assert.NoError(t, valid(t).validate()) }) @@ -229,14 +241,14 @@ func TestCommandDocumentShape(t *testing.T) { require.NotNil(t, b.EndsAt) }) - t.Run("a window the backend left out starts when it decided", func(t *testing.T) { + t.Run("a window the backend left out is left for Materialize to fill", func(t *testing.T) { cmd := valid(t) cmd.StartsAt, cmd.EndsAt = nil, nil b := cmd.banner("en") require.NotNil(t, b) - require.NotNil(t, b.StartsAt) - assert.Equal(t, at, *b.StartsAt, "so a redelivery is identical to the original") + assert.Nil(t, b.StartsAt, "an unstated window keeps the occurrence's own start") assert.Nil(t, b.EndsAt) + assert.Equal(t, at, b.Source.At, "and Materialize starts a first one at the decision") }) t.Run("a clear produces no document", func(t *testing.T) { @@ -573,6 +585,66 @@ func TestApplyCommand(t *testing.T) { assert.Nil(t, storedBanner(t, inst), "a cleared category stays cleared") }) + t.Run("a moved window is applied at both ends", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 70))) + + // The same occurrence, moved wholesale into the next year. Applying + // only the new end would leave a window the backend never asked for. + moved := materialize(t, inst, 71) + starts := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) + ends := time.Date(2027, 3, 10, 0, 0, 0, 0, time.UTC) + moved.StartsAt, moved.EndsAt = &starts, &ends + require.NoError(t, ApplyCommand(moved)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + require.NotNil(t, stored.StartsAt) + require.NotNil(t, stored.EndsAt) + assert.Equal(t, starts, stored.StartsAt.UTC()) + assert.Equal(t, ends, stored.EndsAt.UTC()) + }) + + t.Run("a command that states no window keeps the occurrence's start", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 72))) + began := storedBanner(t, inst).StartsAt + require.NotNil(t, began) + + reworded := materialize(t, inst, 73) + reworded.StartsAt, reworded.EndsAt = nil, nil + reworded.Text["en"] = "We could not charge your card. This is the last attempt." + require.NoError(t, ApplyCommand(reworded)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + require.NotNil(t, stored.StartsAt) + assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), + "rewording an occurrence must not restart it") + }) + + t.Run("an end moved on its own is accepted, even into the past", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 74))) + began := storedBanner(t, inst).StartsAt + require.NotNil(t, began) + + // The backend closes the window without restating the start. Judging + // this at intake against the decision time would refuse it. + ended := materialize(t, inst, 75) + ended.Timestamp = time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC).Unix() + ends := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + ended.StartsAt, ended.EndsAt = nil, &ends + require.NoError(t, ApplyCommand(ended)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + require.NotNil(t, stored.EndsAt) + assert.Equal(t, ends, stored.EndsAt.UTC()) + require.NotNil(t, stored.StartsAt) + assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), "the occurrence keeps its own start") + }) + t.Run("a category the context does not accept is refused", func(t *testing.T) { inst := newInstance(t, refusedContext, "en", "") @@ -632,6 +704,8 @@ func TestApplyCommand(t *testing.T) { require.NoError(t, err) require.NotNil(t, fromRules) assert.Equal(t, BannerIDQuotaExceeded, fromRules.BannerID) + require.NotNil(t, fromRules.StartsAt, "startsAt is not a field a client may find missing") + assert.Equal(t, now, *fromRules.StartsAt) require.NotNil(t, storedBanner(t, inst)) // And the quota slot stays the stack's own, whatever the queue says. diff --git a/model/banner/materialize.go b/model/banner/materialize.go index e7e9ef40118..073a18856f0 100644 --- a/model/banner/materialize.go +++ b/model/banner/materialize.go @@ -23,8 +23,13 @@ func docID(category string) string { return "banner-" + category } // Merge carries a client written dismissal forward: re-materializing the same // occurrence must not resurrect a banner the user has already closed, and only -// a new BannerID clears it. StartsAt is carried the same way, so it stays the -// moment the occurrence began rather than the last re-evaluation. +// a new BannerID clears it. +// +// A nil StartsAt means the producer stated no window, and the moment the +// occurrence began is carried forward instead of moving to this evaluation. +// A producer that states a window owns it: a command moving its own start has +// to replace the stored one, or extending a window applies the new end and +// keeps the old start, which is neither window the producer asked for. func Merge(fresh, stored *Banner) *Banner { merged := fresh.clone() if stored == nil { @@ -34,7 +39,10 @@ func Merge(fresh, stored *Banner) *Banner { merged.DocRev = stored.DocRev if stored.BannerID == fresh.BannerID { merged.DismissedAt = stored.DismissedAt - if stored.StartsAt != nil { + // Carrying the start forward must not invert a window the producer + // just shortened to end before the occurrence began. + if fresh.StartsAt == nil && stored.StartsAt != nil && + (fresh.EndsAt == nil || stored.StartsAt.Before(*fresh.EndsAt)) { at := *stored.StartsAt merged.StartsAt = &at } @@ -64,6 +72,15 @@ func Materialize(db prefixer.Prefixer, category string, fresh *Banner, now time. merged := Merge(fresh, stored) merged.Category = category + // A producer that stated no window starts when it decided: the evaluation + // time for a rule, the decision time for a command. + if merged.StartsAt == nil { + at := merged.Source.At + if at.IsZero() { + at = now + } + merged.StartsAt = &at + } ensureEscapable(merged) stamp(merged, now) diff --git a/model/banner/quota.go b/model/banner/quota.go index e015ab7283e..60c51fd3894 100644 --- a/model/banner/quota.go +++ b/model/banner/quota.go @@ -68,7 +68,8 @@ func EvaluateQuota(state QuotaState, now time.Time) *Banner { } func buildQuotaBanner(id, severity string, priority int, msgid string, state QuotaState, now time.Time) *Banner { - startsAt := now + // No StartsAt: a re-evaluation states no window, so Materialize keeps the + // moment the occurrence began rather than moving it to this evaluation. banner := &Banner{ BannerID: id, Category: CategoryQuota, @@ -78,7 +79,6 @@ func buildQuotaBanner(id, severity string, priority int, msgid string, state Quo Lang: lang(state.Locale), Dismissible: severity != SeverityError, Priority: priority, - StartsAt: &startsAt, Source: Source{Trigger: TriggerUsageThreshold, At: now}, } if target := ctaTarget(state.SettingsURL); target != "" { From 16cb1742754c1dbbbabcbff4a2589760a357ceb7 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 10:14:24 +0100 Subject: [PATCH 05/17] refactor(banners): correct the error comment and the success log ErrInvalidCommand claimed that a transport rejects such a delivery instead of requeuing it. Nothing does: the queue runner nacks every handler error alike, and no code outside the tests inspects the error. The comment now says what happens, and keeps the classification for a transport that learns to reject. The handler logged a command as applied even when ApplyCommand had ignored it, because a stale revision and a successful write both return nil. Since ignoring a stale revision is the normal outcome of at-least-once delivery, that line was telling whoever was working out why a banner never appeared the opposite of what happened. It says accepted now, and names the other outcomes that reach it. Also drops two comments that restated the code below them, and builds the log target by concatenation, which validation makes safe and which the command file already does. --- model/banner/command.go | 12 ++++++------ model/banner/command_test.go | 2 +- pkg/rabbitmq/handlers.go | 16 +++++++--------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/model/banner/command.go b/model/banner/command.go index 8a6de89c7ac..4f267f6bf34 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -19,9 +19,11 @@ import ( // rule the stack runs itself. const TriggerCommand = "banner.command" -// ErrInvalidCommand marks a command no retry can fix. A transport rejects such -// a delivery instead of requeuing it; anything else is a storage failure worth -// retrying. +// ErrInvalidCommand marks a command no retry can fix, as opposed to a storage +// failure worth retrying. Nothing acts on the difference yet: the queue runner +// nacks every handler error alike, so an invalid command is redelivered until +// the broker stops it. The classification is here for a transport that learns +// to reject rather than requeue. var ErrInvalidCommand = errors.New("invalid banner command") // Localized is wording keyed by locale, as the backend sends it. @@ -86,8 +88,7 @@ const ( maxLocaleLen = 35 // MaxCommandBytes bounds the JSON body at the transport boundary. MaxCommandBytes = 256 * 1024 - // maxLocales bounds the locale map, not each value. - maxLocales = 32 + maxLocales = 32 ) // ApplyCommand materializes or clears the banner a backend asked for. @@ -329,7 +330,6 @@ func (cmd Command) validate() error { if len(cmd.EventID) > maxEventIDLen { return fmt.Errorf("%w: eventId is longer than %d bytes", ErrInvalidCommand, maxEventIDLen) } - // A clear carries no presentation fields. if cmd.Clear { if cmd.BannerID != "" || cmd.Severity != "" || cmd.Surface != "" || len(cmd.Title) != 0 || len(cmd.Text) != 0 || cmd.CTA != nil || cmd.SecondaryCTA != nil || diff --git a/model/banner/command_test.go b/model/banner/command_test.go index 745386c7691..d97a7dd659f 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -170,7 +170,7 @@ func TestValidateRejections(t *testing.T) { tc.break_(&cmd) err := cmd.validate() require.Error(t, err) - assert.ErrorIs(t, err, ErrInvalidCommand, "a transport has to know not to retry this") + assert.ErrorIs(t, err, ErrInvalidCommand, "the failure has to be classified as unfixable") assert.Contains(t, err.Error(), tc.want) }) } diff --git a/pkg/rabbitmq/handlers.go b/pkg/rabbitmq/handlers.go index 68e047190cf..579e8694235 100644 --- a/pkg/rabbitmq/handlers.go +++ b/pkg/rabbitmq/handlers.go @@ -865,18 +865,16 @@ func (h *BannerCommandHandler) Handle(ctx context.Context, d amqp.Delivery) erro return fmt.Errorf("banner.commands: unexpected routing key %s", d.RoutingKey) } - // A command the queue cannot fix is nacked like any other failure. The - // queue is declared with a delivery limit, so the broker dead letters it - // after those attempts rather than redelivering it forever. + // A failure is nacked like any other, whether or not a retry could fix it. + // See the queue's delivery limit in the RabbitMQ documentation for what + // bounds the redeliveries. if err := banner.ApplyCommand(cmd); err != nil { return fmt.Errorf("banner.commands: %s revision %d: %w", d.RoutingKey, cmd.Revision, err) } - target := cmd.WorkplaceFqdn - if target == "" { - target = cmd.Domain - } - log.Infof("banner.commands: %s applied to %s (category %s, revision %d, event %s)", - d.RoutingKey, target, cmd.Category, cmd.Revision, cmd.EventID) + // Accepted, not applied: a command that lost to a newer revision, or + // addressed an instance that displays no banner, gets here too. + log.Infof("banner.commands: %s accepted for %s (category %s, revision %d, event %s)", + d.RoutingKey, cmd.Domain+cmd.WorkplaceFqdn, cmd.Category, cmd.Revision, cmd.EventID) return nil } From 4ab9f1d42f3792d5118e83360546c2d1a751408b Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 10:14:31 +0100 Subject: [PATCH 06/17] docs(banners): correct the retry and dead-letter description A missing workplace was described as retried to give an instance still being provisioned time to appear. Nothing in the path delays a redelivery, so the attempts are consumed as fast as the consumer loops and the command reaches the dead letter queue without waiting for anything. It says so now, and says to repair the instance and replay. The delivery limit bounds deliveries rather than retries, which is why a limit of five runs the handler six times on the version the test fixture pins. That number is attributed rather than promised: how a redelivery is counted has changed between RabbitMQ releases, and this stack requeues with basic.nack, so a deployment should confirm it against its own broker. --- docs/banners.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 88506362d0a..4c3d7f404cf 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -135,8 +135,12 @@ not what enables one. The command is rejected, never repaired. An authorized backend can put arbitrary text in front of a user, so anything unexpected in a payload is a backend bug worth surfacing rather than something to guess at. A rejected -command fails the delivery, so the broker redelivers it up to the queue's -`delivery_limit` and then dead letters it. +command fails the delivery, so the broker redelivers it and dead letters it +once the queue's `delivery_limit` is reached. The limit bounds deliveries, not +retries, and on the RabbitMQ version the test fixture pins a limit of 5 runs +the handler six times. Confirm that against the broker a deployment actually +runs: how a redelivery is counted has changed between RabbitMQ releases, and +the stack requeues with `basic.nack`. - `category` matches `^[a-z][a-z0-9-]{0,31}$` and is not `quota`. - exactly one of `domain` and `workplaceFqdn`, each a plain host name. @@ -161,9 +165,11 @@ command fails the delivery, so the broker redelivers it up to the queue's An instance whose context has no `enable_banners` is a no-op rather than a rejection: the backend knows its customers, not which of them display banners. -A workplace that is not here is retried rather than rejected, because the stack -cannot tell a deleted instance from one still being provisioned; the queue's -delivery limit is what bounds those retries. +A workplace that is not here fails the delivery like any other error. Nothing +in the path delays a redelivery, so the attempts are consumed as fast as the +consumer loops rather than spread over any useful interval: it is a rejection +with extra log lines, not a wait for a slow provisioning. Repair the instance +and replay the dead lettered command. ### Authorization From 49db1bff47e54852a60515b3d197b51c32146f9a Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 11:17:01 +0100 Subject: [PATCH 07/17] ref(docs): trim banners.md, remove redundant information --- docs/banners.md | 267 +++++++++++++----------------------------------- 1 file changed, 71 insertions(+), 196 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 4c3d7f404cf..9613435d02f 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -1,48 +1,44 @@ ## Banners -A banner is a platform message displayed to the user by their applications: a -quota warning, a payment problem, a trial about to end. The stack stores one -`io.cozy.banners` document per category and the clients render whatever they -find. There is no computation behind a read: the rules run when an input -changes and the result is written to the instance database. +This is the publisher reference for backend banner commands. The stack stores +one `io.cozy.banners` document per category and instance. See +[ADR 054](https://github.com/linagora/twake-workplace-private/blob/main/documentation/docs/adrs/adr-054.md) +for the platform design. -Banners are off unless the instance context enables them: +### Configuration + +Enable banners and allow the publisher's categories in each recipient context: ```yaml contexts: b2b_twake_default: enable_banners: true + banner_command_categories: + - billing + - trial ``` -Turning the switch back off stops the writes and leaves the documents already -materialized in place, so a rollback needs a cleanup too. +Broker credentials, permissions and bindings control who can publish. Each +category must have one owner; overlapping audiences with independent revision +counters need separate categories. `quota` is reserved for the stack's rules. -### Producers +Disabled instances are skipped. If any enabled recipient disallows the category, +the whole command is rejected before any writes. Disabling banners stops writes +but does not remove existing documents. -Two kinds of producer write the same documents through the same code: +### Commands -- **In-process rules**, for what the stack owns. Disk usage is the only one - today (`model/banner/quota.go`): the stack measures it, so the stack decides, - and the wording comes from its own locale catalogs. -- **A backend on the bus**, for what the stack cannot verify. A payment status, - a dunning step, a trial conversion are decisions another service already - made, so they arrive as commands carrying their own wording. The stack - validates, targets, orders, localizes and stores them; it decides nothing - about what they say. +Publish JSON on the `platform` exchange, consumed by `stack.banner.commands`. +The routing key selects the operation: -A producer never addresses a document. It names a category, and the stack does -the rest. `quota` is reserved to the in-process rules and a command naming it -is rejected. +- `banner.materialize`: create or replace the banner in a category. +- `banner.clear`: delete the banner in a category; nonempty presentation fields + are rejected. -### The command contract +See [RabbitMQ configuration](rabbitmq.md#configuration) for queue declarations +and [shared fixtures](../model/banner/testdata) for complete examples. -Commands are consumed from the `stack.banner.commands` queue with two routing -keys. See [the RabbitMQ documentation](rabbitmq.md) for the queue declaration. -The fixtures in `model/banner/testdata` are the shared examples the publisher -is developed against. - -**`banner.materialize`** puts a banner in a category, replacing whatever that -category holds: +`banner.materialize`: ```json { @@ -54,25 +50,16 @@ category holds: "bannerId": "billing.grace.cycle-a.attempt-2", "severity": "warning", "surface": "banner", - "priority": 150, "dismissible": true, - "title": { "en": "Payment failed", "fr": "Échec du paiement" }, "text": { "en": "We could not charge your card.", "fr": "Nous n'avons pas pu débiter votre carte." }, "cta": { "label": { "en": "Update payment method", "fr": "Mettre à jour le moyen de paiement" }, "url": "https://manager.example.org/linagora/twake_prod/premium" - }, - "secondaryCta": { - "label": { "en": "Contact support", "fr": "Contacter le support" }, - "url": "https://twake.app/support" - }, - "startsAt": "2026-08-01T00:00:00Z", - "endsAt": "2026-08-05T23:30:00Z" + } } ``` -**`banner.clear`** empties a category. It carries the addressing and ordering -fields only. Nonempty presentation fields are rejected: +`banner.clear`: ```json { @@ -84,165 +71,53 @@ fields only. Nonempty presentation fields are rejected: } ``` -| Field | Required | Notes | +| Field | Required | Contract | | --- | --- | --- | -| `category` | always | The slot to write. One document per category per instance. `quota` is refused. | -| `workplaceFqdn` | one of the two | A single instance. | -| `domain` | one of the two | A B2B organization: every instance under it gets the banner. | -| `revision` | always | A positive counter the backend increments per target and category. It is what orders commands. | -| `timestamp` | always | Positive epoch seconds representable in RFC3339, when the backend decided. Provenance, stamped on the document; it orders nothing. | -| `eventId` | no | The backend's correlation id, at most 256 bytes. Logged and retained, never a second ordering mechanism. | -| `bannerId` | materialize | Identifies the occurrence: a new one clears a dismissal, the same one keeps it. | +| `category` | always | Matches `^[a-z][a-z0-9-]{0,31}$`; `quota` is rejected. | +| `workplaceFqdn` / `domain` | exactly one | Plain host name: a single instance / a B2B organization whose members receive the command. | +| `revision` | always | Positive counter, increasing per target and category. | +| `timestamp` | always | Decision time in positive epoch seconds, within the RFC3339 range. Does not order commands. | +| `eventId` | no | Correlation ID, at most 256 bytes. | +| `bannerId` | materialize | Matches `^[a-z0-9.-]{1,64}$`. Keep it for the same occurrence to preserve dismissal; change it for a new occurrence. | | `severity` | materialize | `info`, `warning` or `error`. | | `surface` | materialize | `banner` or `modal`. | -| `text` | materialize | A map keyed by locale, complete in `en`. | -| `title` | no | Same shape as `text`. A client with no title names the dialog from the text. | -| `cta`, `secondaryCta` | no | `url` must be an absolute `https` URL. A secondary action needs a primary one. | -| `dismissible` | no | Defaults to false. A modal with neither a call to action nor a dismissal is made dismissible. | -| `priority` | no | 0 to 1000. The stack's own quota banners sit at 50 and 100. | -| `startsAt`, `endsAt` | no | RFC 3339. A stated `startsAt` replaces the stored one. Omit it to keep the moment the occurrence began, which is the decision time of the command that opened it. | - -The document also carries `source.trigger`, which is `banner.command` for -everything that arrives this way, and `cozyMetadata.createdByApp`, which stays -`stack` whoever asked: a client cannot be made to reason about a per-producer -author. `_id`, `_rev`, `dismissedAt` and `cozyMetadata` are not fields of the -command and a payload carrying them is ignored, not honored. +| `text` | materialize | Locale map with nonempty `en`; at most 1024 bytes per locale. | +| `title` | no | Locale map with nonempty `en` when supplied; at most 256 bytes per locale. | +| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes). A secondary CTA requires a primary one. | +| `dismissible` | no | Defaults to false. A modal without a CTA is made dismissible. | +| `priority` | no | 0–1000; defaults to 0. Quota banners use 50 and 100. | +| `startsAt`, `endsAt` | no | RFC3339. If both are supplied, `startsAt` must precede `endsAt`. An explicit start replaces the stored start; omission preserves it for the same occurrence when compatible with the end, otherwise defaults to the command's decision time. | + +Each locale map accepts at most 32 locales with keys of 1–35 bytes. The JSON +body is limited to 256 KiB, including whitespace and unknown fields. +`_id`, `_rev`, `dismissedAt` and `cozyMetadata` are not command fields and are +ignored if supplied. ### Localization -`text`, `title` and every label are rendered by the backend, not by the stack. -The stack picks **one** locale for the whole banner: the instance's, if every -string the document needs exists in it, and `en` otherwise. `lang` names the -language the user actually reads. Falling back field by field would put a -French sentence above an English button. - -The stack keeps every locale the command carried, on the private -`io.cozy.banners.commands` document, so changing an instance's language picks -one again without the backend publishing anything. The revision, the wording -and the decision time are unchanged: only the language moves. Re-localizing -rewrites a banner rather than restoring one, so a category whose document is -gone stays gone until the next command. A record written before the stack -retained the wording has nothing to pick from, and stays as it is too. - -The languages available for a commanded banner are the ones the backend sends, -not the stack's `consts.SupportedLocales`: the stack renders nothing here, so -its own catalogs have no say. Those catalogs still decide the languages of what -the stack does write itself, the quota banners, and shipping a `.po` file is -not what enables one. - -### Validation - -The command is rejected, never repaired. An authorized backend can put -arbitrary text in front of a user, so anything unexpected in a payload is a -backend bug worth surfacing rather than something to guess at. A rejected -command fails the delivery, so the broker redelivers it and dead letters it -once the queue's `delivery_limit` is reached. The limit bounds deliveries, not -retries, and on the RabbitMQ version the test fixture pins a limit of 5 runs -the handler six times. Confirm that against the broker a deployment actually -runs: how a redelivery is counted has changed between RabbitMQ releases, and -the stack requeues with `basic.nack`. - -- `category` matches `^[a-z][a-z0-9-]{0,31}$` and is not `quota`. -- exactly one of `domain` and `workplaceFqdn`, each a plain host name. -- `revision` and `timestamp` are above zero; the timestamp must serialize as an - RFC3339 time (milliseconds sent as seconds are rejected). -- `bannerId` matches `^[a-z0-9.-]{1,64}$`. -- `severity` is one of `info`, `warning`, `error`. -- `surface` is one of `banner`, `modal`. -- `priority` is between 0 and 1000. Window values must be representable in - RFC3339, and a command that states both ends has `startsAt` before `endsAt`. - A command that states only `endsAt` is not judged here: its start comes from - the stored occurrence, so moving an end alone is allowed. -- `text`, `title` and every label are present in the `en` fallback locale. -- a call to action has an absolute `https` URL, and a secondary one has a - primary alongside it. -- lengths, in bytes, per locale: 256 for a title, 1024 for a text, 128 for a - label, 2048 for a URL, 256 for `eventId`; at most 32 locales per map, - with locale keys of 1–35 bytes. The JSON command is limited to 256 KiB, - including whitespace and unknown fields at the transport boundary. -- clear commands reject nonempty presentation fields, including wording and - windows; their addressing, timestamp and correlation fields are still validated. - -An instance whose context has no `enable_banners` is a no-op rather than a -rejection: the backend knows its customers, not which of them display banners. -A workplace that is not here fails the delivery like any other error. Nothing -in the path delays a redelivery, so the attempts are consumed as fast as the -consumer loops rather than spread over any useful interval: it is a rejection -with extra log lines, not a wait for a slow provisioning. Repair the instance -and replay the dead lettered command. - -### Authorization - -The queue is the authority on who publishes: broker credentials, permissions -and bindings, not a field of the payload. The context settings say what that -publisher is allowed to say: - -```yaml -contexts: - b2b_twake_default: - enable_banners: true - banner_command_categories: - - billing - - trial -``` - -**One category, one owner.** Two producers writing the same category means last -writer wins by counters that were never comparable. Scopes that can be active -at the same time need separate categories. - -A command for a category an enabled member context does not list is rejected -**before any member banner or revision record is written**. Authorization is -checked over the full resolved recipient list first. Repair the configuration -and explicitly replay a rejected command once it has been dead lettered. -Storage failures during fan-out are retried by the broker, and a replay of the -same revision finishes the members that were not reached, because nothing is -recorded for a member whose banner was not written. - -### Ordering and retries - -Bus delivery is at-least-once and unordered, so ordering cannot come from the -arrival time, and it cannot come from the visible document either: a clear -leaves none behind and an unchanged decision writes none. The stack keeps the -last accepted command per instance and category in `io.cozy.banners.commands`, -a separate doctype blocked from public reads and writes, including wildcard -application grants and the bulk/replication API, so an application cannot -rewrite the ordering record. It is a normal document, so it is included in the -instance's backups and migrations. - -Under the instance's banner lock: - -1. A command whose revision is not above the recorded one is ignored. That - covers a redelivery and a stale command alike, including a revision the - backend reused with different wording, which is a backend bug the stack - cannot repair. -2. Otherwise the banner is written first and the record second. A process that - dies between the two leaves the next delivery of that revision to do both - again, and materialization is idempotent, so it heals itself. The reverse - order would record a decision the user never saw. - -This is what makes a clear survive a redelivered materialize, an unchanged -decision advance the ordering, and a partial organization fan-out finish on the -retry. A retry reuses the revision, correlation id and payload of the original; -only a changed decision needs a new revision. The backend allocates them, and -must serialize its own state refresh so a newer revision never carries an older -snapshot. - -Re-publishing an unchanged organization command at its existing revision is how -a member provisioned after the fact is reached: the replay resolves membership -again and leaves the members it already reached untouched. - -### Dismissals and occurrences - -Re-materializing the same `bannerId` keeps a dismissal the user recorded, and -keeps the moment the occurrence began rather than the last evaluation. A new -`bannerId` is a message the user has not seen, so it clears the dismissal. -Escalating a dunning cycle, or starting a new one, is a new occurrence; changing -the wording of the current one is not. - -### What the stack does not do - -Nothing replays on its own: enabling `enable_banners` on a context materializes -nothing until the next command, and a backend that needs its banners to appear -has to publish them again. The stack sends no acknowledgement back: a broker -confirm means the broker accepted the message, not that any instance displays -it. +The publisher supplies all wording. The stack selects the instance's locale +only if it is complete for every supplied text and label; otherwise the whole +banner falls back to `en`. The stored `lang` identifies the selected language. +Any complete publisher-supplied locale is supported, independently of the +stack's translation catalogs. + +On an instance language change, existing banners are re-localized from retained +commands without republishing. Deleted banners and older records without +retained wording are left unchanged. + +### Revisions and recovery + +- Revisions at or below the last accepted revision for an instance and category + are ignored, even after a clear. Only a changed decision needs a new revision; + the publisher must ensure newer revisions carry newer state. +- Retry with the original revision, event ID and payload. Replays complete + partial organization deliveries and reach newly provisioned members while + leaving recipients that already accepted the revision unchanged. +- Enabling banners does not bootstrap them: the publisher must republish. +- Invalid commands and missing workplaces fail delivery. The broker requeues + failures without a delay until its configured delivery limit is exhausted; + configure dead lettering as described in [RabbitMQ](rabbitmq.md#dead-letter-exchange-dlx-and-dead-letter-queue-dlq). + Fix the cause and explicitly replay dead-lettered commands with their original + operation routing key (`banner.materialize` or `banner.clear`). +- The stack sends no application acknowledgement. A broker confirm means the + broker accepted the message, not that a banner was stored or displayed. From 861eb4f05a8bde852b0f13165c7e09d64d6aaffb Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 11:56:36 +0100 Subject: [PATCH 08/17] fix(banners): target organizations by tenant ID --- docs/banners.md | 2 +- model/banner/command.go | 25 ++++++---- model/banner/command_io_test.go | 8 +-- model/banner/command_test.go | 65 +++++++++++++++---------- model/banner/testdata/organization.json | 2 +- pkg/rabbitmq/handlers.go | 2 +- 6 files changed, 62 insertions(+), 42 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 9613435d02f..035f02e6359 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -74,7 +74,7 @@ and [shared fixtures](../model/banner/testdata) for complete examples. | Field | Required | Contract | | --- | --- | --- | | `category` | always | Matches `^[a-z][a-z0-9-]{0,31}$`; `quota` is rejected. | -| `workplaceFqdn` / `domain` | exactly one | Plain host name: a single instance / a B2B organization whose members receive the command. | +| `workplaceFqdn` / `tenant` | exactly one | A single instance host name / a B2B organization ID matching instance `org_id`, whose members receive the command; `tenant` is at most 256 bytes with no surrounding whitespace. | | `revision` | always | Positive counter, increasing per target and category. | | `timestamp` | always | Decision time in positive epoch seconds, within the RFC3339 range. Does not order commands. | | `eventId` | no | Correlation ID, at most 256 bytes. | diff --git a/model/banner/command.go b/model/banner/command.go index 4f267f6bf34..55dcc5c313d 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "strings" "time" "github.com/cozy/cozy-stack/model/instance" @@ -39,9 +40,9 @@ type CommandCTA struct { type Command struct { Category string `json:"category"` - // Exactly one of Domain and WorkplaceFqdn is set. Domain addresses a - // B2B organization, and every instance under it gets the banner. - Domain string `json:"domain,omitempty"` + // Exactly one of Tenant and WorkplaceFqdn is set. Tenant is the B2B + // organization ID, and every instance under it gets the banner. + Tenant string `json:"tenant,omitempty"` WorkplaceFqdn string `json:"workplaceFqdn,omitempty"` // EventID is the backend's correlation id, logged for traceability. @@ -85,6 +86,7 @@ const ( maxURLLen = 2048 maxPriority = 1000 maxEventIDLen = 256 + maxTenantLen = 256 maxLocaleLen = 35 // MaxCommandBytes bounds the JSON body at the transport boundary. MaxCommandBytes = 256 * 1024 @@ -119,10 +121,10 @@ func ApplyCommand(cmd Command) error { // instance is a no-op. A missing workplace is retryable (not invalid): the // stack cannot tell a deleted instance from one still being provisioned. func (cmd Command) targets() ([]*instance.Instance, error) { - if cmd.Domain != "" { - list, err := lifecycle.ListOrgInstances(cmd.Domain) + if cmd.Tenant != "" { + list, err := lifecycle.ListOrgInstancesByID(cmd.Tenant) if err != nil { - return nil, fmt.Errorf("cannot list the instances of organization %s: %w", cmd.Domain, err) + return nil, fmt.Errorf("cannot list the instances of organization %s: %w", cmd.Tenant, err) } return list, nil } @@ -312,11 +314,14 @@ func (cmd Command) validate() error { return fmt.Errorf("%w: the %s category is reserved for the stack's own rules", ErrInvalidCommand, CategoryQuota) } - if (cmd.Domain == "") == (cmd.WorkplaceFqdn == "") { - return fmt.Errorf("%w: exactly one of domain and workplaceFqdn is required", ErrInvalidCommand) + if (cmd.Tenant == "") == (cmd.WorkplaceFqdn == "") { + return fmt.Errorf("%w: exactly one of tenant and workplaceFqdn is required", ErrInvalidCommand) } - if target := cmd.Domain + cmd.WorkplaceFqdn; !targetFormat.MatchString(target) { - return fmt.Errorf("%w: %q is not a valid target", ErrInvalidCommand, target) + if cmd.Tenant != "" && (len(cmd.Tenant) > maxTenantLen || strings.TrimSpace(cmd.Tenant) != cmd.Tenant) { + return fmt.Errorf("%w: tenant must be at most %d bytes with no surrounding whitespace", ErrInvalidCommand, maxTenantLen) + } + if cmd.WorkplaceFqdn != "" && !targetFormat.MatchString(cmd.WorkplaceFqdn) { + return fmt.Errorf("%w: %q is not a valid target", ErrInvalidCommand, cmd.WorkplaceFqdn) } if cmd.Revision <= 0 { return fmt.Errorf("%w: a positive revision is required", ErrInvalidCommand) diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go index 0382de1384b..a2d91c3b39c 100644 --- a/model/banner/command_io_test.go +++ b/model/banner/command_io_test.go @@ -25,18 +25,18 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { needCouchDB(t) useCommandContexts(t) first := newInstance(t, commandContext, "en", "") - org := first.Domain - first.OrgDomain = org + org := "org-" + first.Domain + first.OrgID = org // The instance helper registers cleanup; creation of the other members // uses the same org without assuming CouchDB's member ordering. require.NoError(t, couchdb.UpdateDoc(prefixer.GlobalPrefixer, first)) newInstance(t, commandContext, "fr", org) newInstance(t, commandContext, "en", org) - members, err := lifecycle.ListOrgInstances(org) + members, err := lifecycle.ListOrgInstancesByID(org) require.NoError(t, err) require.Len(t, members, 3) cmd := fixture(t, "organization") - cmd.Domain = org + cmd.Tenant = org failPath := "/" + couchdb.EscapeCouchdbName(members[1].DBPrefix()+"/"+consts.Banners) + "/banner-billing" client := config.CouchClient() original := client.Transport diff --git a/model/banner/command_test.go b/model/banner/command_test.go index d97a7dd659f..5b2c25d108f 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -14,6 +14,7 @@ import ( "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/prefixer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,7 +45,7 @@ func TestFixturesAreTheContract(t *testing.T) { cmd := fixture(t, "materialize") assert.Equal(t, "alice.twake.app", cmd.WorkplaceFqdn) - assert.Empty(t, cmd.Domain) + assert.Empty(t, cmd.Tenant) assert.Equal(t, "banner-command-42", cmd.EventID) assert.Equal(t, int64(42), cmd.Revision) assert.Equal(t, int64(decidedAt), cmd.Timestamp) @@ -73,10 +74,10 @@ func TestFixturesAreTheContract(t *testing.T) { assert.NoError(t, cmd.validate()) }) - t.Run("an organization is addressed by its domain", func(t *testing.T) { + t.Run("an organization is addressed by its tenant ID", func(t *testing.T) { cmd := fixture(t, "organization") - assert.Equal(t, "acme.example", cmd.Domain) + assert.Equal(t, "acme_org:123", cmd.Tenant) assert.Empty(t, cmd.WorkplaceFqdn) assert.Equal(t, SurfaceModal, cmd.Surface) assert.NoError(t, cmd.validate()) @@ -118,8 +119,11 @@ func TestValidateRejections(t *testing.T) { {"category starting with a digit", func(c *Command) { c.Category = "2fa" }, "not a valid category"}, {"category too long", func(c *Command) { c.Category = strings.Repeat("a", 33) }, "not a valid category"}, {"the quota category", func(c *Command) { c.Category = CategoryQuota }, "reserved for the stack's own rules"}, - {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of domain and workplaceFqdn"}, - {"both targets", func(c *Command) { c.Domain = "acme.example" }, "exactly one of domain and workplaceFqdn"}, + {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of tenant and workplaceFqdn"}, + {"both targets", func(c *Command) { c.Tenant = "acme.example" }, "exactly one of tenant and workplaceFqdn"}, + {"tenant too long", func(c *Command) { c.WorkplaceFqdn = ""; c.Tenant = strings.Repeat("a", maxTenantLen+1) }, "tenant must be at most"}, + {"blank tenant", func(c *Command) { c.WorkplaceFqdn = ""; c.Tenant = " " }, "no surrounding whitespace"}, + {"tenant with surrounding whitespace", func(c *Command) { c.WorkplaceFqdn = ""; c.Tenant = " acme" }, "no surrounding whitespace"}, {"a target with a path", func(c *Command) { c.WorkplaceFqdn = "alice.twake.app/../bob" }, "is not a valid target"}, {"a target with a scheme", func(c *Command) { c.WorkplaceFqdn = "https://alice.twake.app" }, "is not a valid target"}, {"a target too long", func(c *Command) { c.WorkplaceFqdn = strings.Repeat("a", 256) }, "is not a valid target"}, @@ -210,7 +214,7 @@ func TestValidateRejections(t *testing.T) { {"oversized event id", func(c *Command) { c.EventID = strings.Repeat("e", maxEventIDLen+1) }, "eventId is longer than"}, {"oversized wording", func(c *Command) { c.Text = Localized{"en": strings.Repeat("x", 2<<20)} }, "clear must not carry presentation"}, {"ordinary wording", func(c *Command) { c.Text = Localized{"en": "ignored?"} }, "clear must not carry presentation"}, - {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of domain and workplaceFqdn"}, + {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of tenant and workplaceFqdn"}, {"the quota category", func(c *Command) { c.Category = CategoryQuota }, "reserved"}, } { cmd := clear() @@ -344,7 +348,7 @@ func useCommandContexts(t *testing.T) { t.Cleanup(func() { conf.Contexts = previous }) } -func newInstance(t *testing.T, contextName, locale, orgDomain string) *instance.Instance { +func newInstance(t *testing.T, contextName, locale, orgID string) *instance.Instance { t.Helper() domain := fmt.Sprintf("banner-cmd-%d.example", time.Now().UnixNano()) inst, err := lifecycle.Create(&lifecycle.Options{ @@ -352,7 +356,7 @@ func newInstance(t *testing.T, contextName, locale, orgDomain string) *instance. Email: "alice@example.org", Locale: locale, ContextName: contextName, - OrgDomain: orgDomain, + OrgID: orgID, }) require.NoError(t, err) t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) @@ -724,20 +728,24 @@ func TestApplyCommandToAnOrganization(t *testing.T) { needCouchDB(t) useCommandContexts(t) - orgCommand := func(t *testing.T, orgDomain string, revision int64) Command { + orgCommand := func(t *testing.T, orgID string, revision int64) Command { t.Helper() cmd := fixture(t, "organization") - cmd.Domain = orgDomain + cmd.Tenant = orgID cmd.Revision = revision return cmd } t.Run("every instance of the organization gets the banner", func(t *testing.T) { - orgDomain := fmt.Sprintf("acme-%d.example", time.Now().UnixNano()) - first := newInstance(t, commandContext, "en", orgDomain) - second := newInstance(t, commandContext, "fr", orgDomain) + orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) + first := newInstance(t, commandContext, "en", orgID) + second := newInstance(t, commandContext, "fr", orgID) - require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + other := newInstance(t, commandContext, "en", orgID+"-other") + other.OrgDomain = orgID + require.NoError(t, couchdb.UpdateDoc(prefixer.GlobalPrefixer, other)) + + require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) for _, inst := range []*instance.Instance{first, second} { stored := storedBanner(t, inst) @@ -745,28 +753,35 @@ func TestApplyCommandToAnOrganization(t *testing.T) { assert.Equal(t, "billing.restricted", stored.BannerID) } assert.Equal(t, "fr", storedBanner(t, second).Lang, "each member reads its own language") + assert.Nil(t, storedBanner(t, other), "a matching organization domain must not select another tenant") + + clear := orgCommand(t, orgID, 8) + clear = Command{Category: clear.Category, Tenant: clear.Tenant, Revision: clear.Revision, Timestamp: clear.Timestamp, Clear: true} + require.NoError(t, ApplyCommand(clear)) + assert.Nil(t, storedBanner(t, first)) + assert.Nil(t, storedBanner(t, second)) }) t.Run("a replay reaches a member provisioned after the command", func(t *testing.T) { - orgDomain := fmt.Sprintf("acme-%d.example", time.Now().UnixNano()) - first := newInstance(t, commandContext, "en", orgDomain) - require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) + first := newInstance(t, commandContext, "en", orgID) + require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) before := storedBanner(t, first) require.NotNil(t, before) - joined := newInstance(t, commandContext, "en", orgDomain) - require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + joined := newInstance(t, commandContext, "en", orgID) + require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) require.NotNil(t, storedBanner(t, joined), "an equal revision resolves membership again") assert.Equal(t, before.DocRev, storedBanner(t, first).DocRev, "and leaves the members it already reached alone") }) t.Run("a refused category rejects the organization before any writes", func(t *testing.T) { - orgDomain := fmt.Sprintf("acme-%d.example", time.Now().UnixNano()) - accepting := newInstance(t, commandContext, "en", orgDomain) - refusing := newInstance(t, refusedContext, "en", orgDomain) + orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) + accepting := newInstance(t, commandContext, "en", orgID) + refusing := newInstance(t, refusedContext, "en", orgID) - err := ApplyCommand(orgCommand(t, orgDomain, 7)) + err := ApplyCommand(orgCommand(t, orgID, 7)) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidCommand) assert.ErrorContains(t, err, refusing.Domain) @@ -789,13 +804,13 @@ func TestApplyCommandToAnOrganization(t *testing.T) { // A permanent rejection needs an explicit replay after configuration // repair; it is not an automatic broker retry. - require.NoError(t, ApplyCommand(orgCommand(t, orgDomain, 7))) + require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) assert.NotNil(t, storedBanner(t, accepting)) assert.NotNil(t, storedBanner(t, refusing)) }) t.Run("an organization with no instance is a no-op", func(t *testing.T) { - assert.NoError(t, ApplyCommand(orgCommand(t, fmt.Sprintf("empty-%d.example", time.Now().UnixNano()), 7))) + assert.NoError(t, ApplyCommand(orgCommand(t, fmt.Sprintf("empty-org-%d", time.Now().UnixNano()), 7))) }) } diff --git a/model/banner/testdata/organization.json b/model/banner/testdata/organization.json index 7b65b0abbf3..a429b5678c2 100644 --- a/model/banner/testdata/organization.json +++ b/model/banner/testdata/organization.json @@ -1,5 +1,5 @@ { - "domain": "acme.example", + "tenant": "acme_org:123", "eventId": "banner-command-7", "revision": 7, "timestamp": 1788944400, diff --git a/pkg/rabbitmq/handlers.go b/pkg/rabbitmq/handlers.go index 579e8694235..7943b583078 100644 --- a/pkg/rabbitmq/handlers.go +++ b/pkg/rabbitmq/handlers.go @@ -875,6 +875,6 @@ func (h *BannerCommandHandler) Handle(ctx context.Context, d amqp.Delivery) erro // Accepted, not applied: a command that lost to a newer revision, or // addressed an instance that displays no banner, gets here too. log.Infof("banner.commands: %s accepted for %s (category %s, revision %d, event %s)", - d.RoutingKey, cmd.Domain+cmd.WorkplaceFqdn, cmd.Category, cmd.Revision, cmd.EventID) + d.RoutingKey, cmd.Tenant+cmd.WorkplaceFqdn, cmd.Category, cmd.Revision, cmd.EventID) return nil } From 2328c50790842482a92c03892410ced74f7611b2 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Fri, 11 Sep 2026 12:01:13 +0100 Subject: [PATCH 09/17] fix(banners): skip instances that disallow command categories --- docs/banners.md | 6 +++--- model/banner/command.go | 10 ++-------- model/banner/command_test.go | 33 +++++++++++++++------------------ 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 035f02e6359..188b28c3975 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -22,9 +22,9 @@ Broker credentials, permissions and bindings control who can publish. Each category must have one owner; overlapping audiences with independent revision counters need separate categories. `quota` is reserved for the stack's rules. -Disabled instances are skipped. If any enabled recipient disallows the category, -the whole command is rejected before any writes. Disabling banners stops writes -but does not remove existing documents. +Instances that disable banners or disallow the category are skipped; other +eligible recipients still receive the command. Skipping an instance leaves its +existing documents and recorded revision unchanged. ### Commands diff --git a/model/banner/command.go b/model/banner/command.go index 55dcc5c313d..5f6d603d8f0 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -103,12 +103,6 @@ func ApplyCommand(cmd Command) error { if err != nil { return err } - for _, inst := range instances { - if inst.HasBannersEnabled() && !inst.AllowsBannerCategory(cmd.Category) { - return fmt.Errorf("%w: the context of %s does not accept commands for the %s category", - ErrInvalidCommand, inst.Domain, cmd.Category) - } - } for _, inst := range instances { if err := cmd.applyTo(inst); err != nil { return fmt.Errorf("%s: %w", inst.Domain, err) @@ -136,8 +130,8 @@ func (cmd Command) targets() ([]*instance.Instance, error) { } func (cmd Command) applyTo(inst *instance.Instance) error { - // An instance that displays no banner is a no-op, not a rejection. - if !inst.HasBannersEnabled() { + // Instances that disable banners or disallow this category are skipped. + if !inst.HasBannersEnabled() || !inst.AllowsBannerCategory(cmd.Category) { return nil } diff --git a/model/banner/command_test.go b/model/banner/command_test.go index 5b2c25d108f..fd1aeaa835d 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -649,13 +649,14 @@ func TestApplyCommand(t *testing.T) { assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), "the occurrence keeps its own start") }) - t.Run("a category the context does not accept is refused", func(t *testing.T) { + t.Run("a category the context does not accept is skipped", func(t *testing.T) { inst := newInstance(t, refusedContext, "en", "") - err := ApplyCommand(materialize(t, inst, 80)) - assert.ErrorIs(t, err, ErrInvalidCommand) - assert.ErrorContains(t, err, "does not accept commands for the billing category") + require.NoError(t, ApplyCommand(materialize(t, inst, 80))) assert.Nil(t, storedBanner(t, inst)) + stored, err := storedCommand(inst, CategoryBilling) + require.NoError(t, err) + assert.Nil(t, stored, "skipping must not advance the revision") }) t.Run("an instance that displays no banner is a no-op", func(t *testing.T) { @@ -776,22 +777,18 @@ func TestApplyCommandToAnOrganization(t *testing.T) { assert.Equal(t, before.DocRev, storedBanner(t, first).DocRev, "and leaves the members it already reached alone") }) - t.Run("a refused category rejects the organization before any writes", func(t *testing.T) { + t.Run("a disallowed category skips only that instance", func(t *testing.T) { orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) accepting := newInstance(t, commandContext, "en", orgID) refusing := newInstance(t, refusedContext, "en", orgID) - err := ApplyCommand(orgCommand(t, orgID, 7)) - require.Error(t, err) - assert.ErrorIs(t, err, ErrInvalidCommand) - assert.ErrorContains(t, err, refusing.Domain) - assert.Nil(t, storedBanner(t, accepting)) + require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) + before := storedBanner(t, accepting) + require.NotNil(t, before) assert.Nil(t, storedBanner(t, refusing)) - for _, inst := range []*instance.Instance{accepting, refusing} { - stored, err := storedCommand(inst, CategoryBilling) - require.NoError(t, err) - assert.Nil(t, stored, "rejection must not advance a member's revision") - } + stored, err := storedCommand(refusing, CategoryBilling) + require.NoError(t, err) + assert.Nil(t, stored, "skipping must not advance the member's revision") conf := config.GetConfig() conf.Contexts[refusedContext] = map[string]interface{}{ @@ -802,10 +799,10 @@ func TestApplyCommandToAnOrganization(t *testing.T) { conf.Contexts[refusedContext] = map[string]interface{}{"enable_banners": true} }) - // A permanent rejection needs an explicit replay after configuration - // repair; it is not an automatic broker retry. + // Replay reaches a previously skipped member after its context allows + // the category, without changing members that already accepted it. require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) - assert.NotNil(t, storedBanner(t, accepting)) + assert.Equal(t, before.DocRev, storedBanner(t, accepting).DocRev) assert.NotNil(t, storedBanner(t, refusing)) }) From 2816d56052c99f5d5fd26f134180ffe72adf9556 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 12:29:15 +0100 Subject: [PATCH 10/17] fix: update banner documents to retain command state --- docs/banners.md | 17 +++- model/banner/banner.go | 32 ++++++++ model/banner/command.go | 112 +++++---------------------- model/banner/command_io_test.go | 12 +-- model/banner/command_test.go | 81 +++++++++++++++---- model/banner/materialize.go | 12 ++- model/banner/trigger.go | 25 +++--- model/permission/doctype.go | 1 - pkg/consts/doctype.go | 3 - pkg/rabbitmq/banner_commands_test.go | 22 ++++-- pkg/rabbitmq/handlers_test.go | 5 +- web/data/banner_commands_test.go | 66 +++++----------- 12 files changed, 193 insertions(+), 195 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 188b28c3975..ea55d53c6bb 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -32,7 +32,7 @@ Publish JSON on the `platform` exchange, consumed by `stack.banner.commands`. The routing key selects the operation: - `banner.materialize`: create or replace the banner in a category. -- `banner.clear`: delete the banner in a category; nonempty presentation fields +- `banner.clear`: expire the banner in a category while retaining its revision; nonempty presentation fields are rejected. See [RabbitMQ configuration](rabbitmq.md#configuration) for queue declarations @@ -102,11 +102,22 @@ Any complete publisher-supplied locale is supported, independently of the stack's translation catalogs. On an instance language change, existing banners are re-localized from retained -commands without republishing. Deleted banners and older records without -retained wording are left unchanged. +commands in the banner documents without republishing. Cleared or deleted +banners and older records without retained wording are left unchanged. ### Revisions and recovery +Commanded banners store `revision`, `eventId`, and the full localized command +in `accepted` alongside their presentation. A clear retains the category's +document with `cleared: true`, an expired `endsAt`, and no retained wording; +clients must filter out banners whose validity window has ended. A newer +materialize replaces it normally. Updating the command revision also updates +the document revision, even when its visible wording is unchanged. + +These fields use the same app permissions as the banner. Apps recording a +dismissal should preserve the other fields and use the current CouchDB `_rev`; +editing or deleting the ordering state can allow stale commands to be replayed. + - Revisions at or below the last accepted revision for an instance and category are ignored, even after a clear. Only a changed decision needs a new revision; the publisher must ensure newer revisions carry newer state. diff --git a/model/banner/banner.go b/model/banner/banner.go index e5bcb8f9a5a..ff4ca0a2727 100644 --- a/model/banner/banner.go +++ b/model/banner/banner.go @@ -7,6 +7,7 @@ package banner import ( + "maps" "time" "github.com/cozy/cozy-stack/pkg/consts" @@ -78,6 +79,13 @@ type Banner struct { EndsAt *time.Time `json:"endsAt,omitempty"` Source Source `json:"source"` + // Command state shares the document's ordinary app permissions. A clear + // expires the banner instead of deleting its ordering history. + Revision int64 `json:"revision,omitempty"` + EventID string `json:"eventId,omitempty"` + Cleared bool `json:"cleared,omitempty"` + Accepted *Command `json:"accepted,omitempty"` + Metadata *metadata.CozyMetadata `json:"cozyMetadata,omitempty"` } @@ -111,6 +119,30 @@ func (b *Banner) clone() *Banner { at := *b.EndsAt cloned.EndsAt = &at } + if b.Accepted != nil { + cmd := *b.Accepted + cmd.Title = maps.Clone(cmd.Title) + cmd.Text = maps.Clone(cmd.Text) + if cmd.CTA != nil { + cta := *cmd.CTA + cta.Label = maps.Clone(cta.Label) + cmd.CTA = &cta + } + if cmd.SecondaryCTA != nil { + cta := *cmd.SecondaryCTA + cta.Label = maps.Clone(cta.Label) + cmd.SecondaryCTA = &cta + } + if cmd.StartsAt != nil { + at := *cmd.StartsAt + cmd.StartsAt = &at + } + if cmd.EndsAt != nil { + at := *cmd.EndsAt + cmd.EndsAt = &at + } + cloned.Accepted = &cmd + } if b.Metadata != nil { cloned.Metadata = b.Metadata.Clone() } diff --git a/model/banner/command.go b/model/banner/command.go index 5f6d603d8f0..a1aa279c8e0 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -12,8 +12,6 @@ import ( "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" - "github.com/cozy/cozy-stack/pkg/couchdb" - "github.com/cozy/cozy-stack/pkg/prefixer" ) // TriggerCommand is recorded on documents a backend asked for rather than a @@ -143,7 +141,7 @@ func (cmd Command) applyTo(inst *instance.Instance) error { } defer mu.Unlock() - stored, err := storedCommand(inst, cmd.Category) + stored, err := Stored(inst, cmd.Category) if err != nil { return err } @@ -153,102 +151,26 @@ func (cmd Command) applyTo(inst *instance.Instance) error { return nil } - // Banner before record: a crash in between is healed by the next - // delivery (Materialize is idempotent). The reverse would record a - // decision the user never saw. - if err := Materialize(inst, cmd.Category, cmd.banner(inst.Locale), time.Now()); err != nil { - return err - } - - next := cmd.state() - if stored != nil { - next.DocID, next.DocRev = stored.DocID, stored.DocRev - return couchdb.UpdateDoc(inst, next) - } - next.DocID = docID(cmd.Category) - return couchdb.CreateNamedDocWithDB(inst, next) + return Materialize(inst, cmd.Category, cmd.banner(inst.Locale), time.Now()) } -// commandState records the last command accepted for a category, so ordering -// survives clears (which leave no public document) and unchanged decisions. -// It keeps the command whole, so the stack can pick the language again when -// the instance changes locale without the backend publishing anything. The -// doctype is blocklisted, so none of this is reachable from an application. -type commandState struct { - DocID string `json:"_id,omitempty"` - DocRev string `json:"_rev,omitempty"` - - Category string `json:"category"` - Revision int64 `json:"revision"` - Clear bool `json:"clear"` - EventID string `json:"eventId,omitempty"` - - // Accepted is the command as it arrived, with every locale the backend - // sent. Absent on a clear, and on a record written before the stack - // retained it: only a new command can refresh one of those. - Accepted *Command `json:"accepted,omitempty"` -} - -func (d *commandState) ID() string { return d.DocID } -func (d *commandState) Rev() string { return d.DocRev } -func (d *commandState) DocType() string { return consts.BannerCommands } -func (d *commandState) SetID(id string) { d.DocID = id } -func (d *commandState) SetRev(rev string) { d.DocRev = rev } -func (d *commandState) Clone() couchdb.Doc { cloned := *d; return &cloned } - -var _ couchdb.Doc = &commandState{} - -func (cmd Command) state() *commandState { - state := &commandState{ - Category: cmd.Category, - Revision: cmd.Revision, - Clear: cmd.Clear, - EventID: cmd.EventID, - } - // A clear has no wording to keep, and its category holds no document to - // re-localize. - if !cmd.Clear { - accepted := cmd - state.Accepted = &accepted - } - return state -} - -// storedCommands returns the state retained for every category of an instance. -func storedCommands(db prefixer.Prefixer) ([]*commandState, error) { - var states []*commandState - err := couchdb.GetAllDocs(db, consts.BannerCommands, nil, &states) - if couchdb.IsNoDatabaseError(err) { - return nil, nil - } - if err != nil { - return nil, err - } - return states, nil -} - -// storedCommand returns the state retained for a category, or nil when none -// exists (including a missing database, which is the first command on an instance). -func storedCommand(db prefixer.Prefixer, category string) (*commandState, error) { - var doc commandState - err := couchdb.GetDoc(db, consts.BannerCommands, docID(category), &doc) - if couchdb.IsNotFoundError(err) || couchdb.IsNoDatabaseError(err) { - return nil, nil - } - if err != nil { - return nil, err - } - return &doc, nil -} - -// banner is the document the command asks for, or nil for a clear. +// banner keeps the accepted command with the localized presentation. Clears +// retain the revision in an expired document, including when no banner existed. func (cmd Command) banner(instanceLocale string) *Banner { + at := time.Unix(cmd.Timestamp, 0).UTC() if cmd.Clear { - return nil + ended := time.Unix(0, 0).UTC() + return &Banner{ + Category: cmd.Category, + Revision: cmd.Revision, + EventID: cmd.EventID, + Cleared: true, + EndsAt: &ended, + Source: Source{Trigger: TriggerCommand, At: at}, + } } - at := time.Unix(cmd.Timestamp, 0).UTC() locale := cmd.locale(instanceLocale) - b := &Banner{ + return &Banner{ BannerID: cmd.BannerID, Category: cmd.Category, Severity: cmd.Severity, @@ -263,8 +185,10 @@ func (cmd Command) banner(instanceLocale string) *Banner { StartsAt: cmd.StartsAt, EndsAt: cmd.EndsAt, Source: Source{Trigger: TriggerCommand, At: at}, + Revision: cmd.Revision, + EventID: cmd.EventID, + Accepted: &cmd, } - return b } // locale picks one language for the whole banner. A locale the backend only diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go index a2d91c3b39c..b70d3f79790 100644 --- a/model/banner/command_io_test.go +++ b/model/banner/command_io_test.go @@ -55,7 +55,7 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { require.NotNil(t, before) assert.Nil(t, storedBanner(t, members[1])) assert.Nil(t, storedBanner(t, members[2])) - retained, err := storedCommand(members[1], CategoryBilling) + retained, err := Stored(members[1], CategoryBilling) require.NoError(t, err) require.Nil(t, retained, "nothing is recorded for a member whose banner was not written") require.NoError(t, ApplyCommand(cmd)) @@ -77,16 +77,16 @@ func TestCommandClearRetriesProjectionFailure(t *testing.T) { t.Cleanup(func() { client.Transport = original }) var failed atomic.Bool client.Transport = commandRoundTripper(func(r *http.Request) (*http.Response, error) { - if r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/banner-billing") && failed.CompareAndSwap(false, true) { - return nil, errors.New("simulated delete outage") + if r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/banner-billing") && failed.CompareAndSwap(false, true) { + return nil, errors.New("simulated clear outage") } return original.RoundTrip(r) }) clear := clearCommand(t, inst, 2) - require.ErrorContains(t, ApplyCommand(clear), "simulated delete outage") - retained, err := storedCommand(inst, CategoryBilling) + require.ErrorContains(t, ApplyCommand(clear), "simulated clear outage") + retained, err := Stored(inst, CategoryBilling) require.NoError(t, err) - require.False(t, retained.Clear, "the clear is only recorded once the document is gone") + require.False(t, retained.Cleared, "a failed clear must leave the previous decision intact") require.Equal(t, int64(1), retained.Revision) require.NoError(t, ApplyCommand(old)) require.NoError(t, ApplyCommand(clear)) diff --git a/model/banner/command_test.go b/model/banner/command_test.go index fd1aeaa835d..b17be99b712 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -255,10 +255,15 @@ func TestCommandDocumentShape(t *testing.T) { assert.Equal(t, at, b.Source.At, "and Materialize starts a first one at the decision") }) - t.Run("a clear produces no document", func(t *testing.T) { + t.Run("a clear produces an expired ordering record", func(t *testing.T) { cmd := valid(t) cmd.Clear = true - assert.Nil(t, cmd.banner("en")) + b := cmd.banner("en") + require.NotNil(t, b) + assert.True(t, b.Cleared) + require.NotNil(t, b.EndsAt) + assert.True(t, b.EndsAt.Before(time.Now())) + assert.Nil(t, b.Accepted) }) } @@ -386,12 +391,15 @@ func storedBanner(t *testing.T, inst *instance.Instance) *Banner { t.Helper() stored, err := Stored(inst, CategoryBilling) require.NoError(t, err) + if stored != nil && stored.Cleared { + return nil + } return stored } -func storedState(t *testing.T, inst *instance.Instance) *commandState { +func storedState(t *testing.T, inst *instance.Instance) *Banner { t.Helper() - stored, err := storedCommand(inst, CategoryBilling) + stored, err := Stored(inst, CategoryBilling) require.NoError(t, err) require.NotNil(t, stored) return stored @@ -427,7 +435,7 @@ func TestApplyCommand(t *testing.T) { assert.Equal(t, TriggerCommand, stored.Source.Trigger) }) - t.Run("an unchanged newer decision advances the ordering without rewriting", func(t *testing.T) { + t.Run("an unchanged newer decision persists its ordering", func(t *testing.T) { inst := newInstance(t, commandContext, "en", "") require.NoError(t, ApplyCommand(materialize(t, inst, 10))) created := storedBanner(t, inst) @@ -436,7 +444,8 @@ func TestApplyCommand(t *testing.T) { require.NoError(t, ApplyCommand(materialize(t, inst, 11))) again := storedBanner(t, inst) require.NotNil(t, again) - assert.Equal(t, created.DocRev, again.DocRev, "an unchanged banner must not wake the realtime clients") + assert.NotEqual(t, created.DocRev, again.DocRev) + assert.Equal(t, int64(11), again.Revision) // The stale clear is what the old timestamp guard let through: the // document it would compare against never moved. @@ -444,7 +453,19 @@ func TestApplyCommand(t *testing.T) { assert.NotNil(t, storedBanner(t, inst), "a clear older than the last decision changes nothing") }) - t.Run("a clear removes the document and outlives a stale materialize", func(t *testing.T) { + t.Run("a newer translation is retained even when the displayed language is unchanged", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + original := materialize(t, inst, 12) + require.NoError(t, ApplyCommand(original)) + updated := materialize(t, inst, 13) + updated.Text["fr"] = "Veuillez vérifier votre carte." + require.NoError(t, ApplyCommand(updated)) + assert.Equal(t, original.Text["en"], storedBanner(t, inst).Text) + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "fr"})) + assert.Equal(t, updated.Text["fr"], storedBanner(t, inst).Text) + }) + + t.Run("a clear expires the document and outlives a stale materialize", func(t *testing.T) { inst := newInstance(t, commandContext, "en", "") require.NoError(t, ApplyCommand(materialize(t, inst, 20))) require.NotNil(t, storedBanner(t, inst)) @@ -453,7 +474,20 @@ func TestApplyCommand(t *testing.T) { assert.Nil(t, storedBanner(t, inst)) require.NoError(t, ApplyCommand(materialize(t, inst, 20))) - assert.Nil(t, storedBanner(t, inst), "the cleared category keeps its revision with no document to hold it") + assert.Nil(t, storedBanner(t, inst), "the expired document keeps the cleared revision") + }) + + t.Run("materializing after a clear starts a fresh occurrence", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(materialize(t, inst, 22))) + dismiss(t, inst) + require.NoError(t, ApplyCommand(clearCommand(t, inst, 23))) + cleared := storedState(t, inst) + require.True(t, cleared.Cleared) + assert.Nil(t, cleared.Accepted) + require.NoError(t, ApplyCommand(materialize(t, inst, 24))) + require.NotNil(t, storedBanner(t, inst)) + assert.Nil(t, storedBanner(t, inst).DismissedAt) }) t.Run("a redelivery of the same revision changes nothing", func(t *testing.T) { @@ -654,7 +688,7 @@ func TestApplyCommand(t *testing.T) { require.NoError(t, ApplyCommand(materialize(t, inst, 80))) assert.Nil(t, storedBanner(t, inst)) - stored, err := storedCommand(inst, CategoryBilling) + stored, err := Stored(inst, CategoryBilling) require.NoError(t, err) assert.Nil(t, stored, "skipping must not advance the revision") }) @@ -786,7 +820,7 @@ func TestApplyCommandToAnOrganization(t *testing.T) { before := storedBanner(t, accepting) require.NotNil(t, before) assert.Nil(t, storedBanner(t, refusing)) - stored, err := storedCommand(refusing, CategoryBilling) + stored, err := Stored(refusing, CategoryBilling) require.NoError(t, err) assert.Nil(t, stored, "skipping must not advance the member's revision") @@ -811,7 +845,7 @@ func TestApplyCommandToAnOrganization(t *testing.T) { }) } -func TestCommandStateIsNotInTheAppWritableDoctype(t *testing.T) { +func TestCommandStateSharesTheBannerDocument(t *testing.T) { config.UseTestFile(t) needCouchDB(t) useCommandContexts(t) @@ -819,13 +853,28 @@ func TestCommandStateIsNotInTheAppWritableDoctype(t *testing.T) { inst := newInstance(t, commandContext, "en", "") require.NoError(t, ApplyCommand(materialize(t, inst, 42))) - stored, err := storedCommand(inst, CategoryBilling) + stored, err := Stored(inst, CategoryBilling) require.NoError(t, err) require.NotNil(t, stored) - assert.Equal(t, consts.BannerCommands, stored.DocType()) - assert.NotEqual(t, consts.Banners, stored.DocType(), - "an application allowed to record a dismissal must not reach the ordering record") + assert.Equal(t, consts.Banners, stored.DocType()) assert.Equal(t, int64(42), stored.Revision) - assert.False(t, stored.Clear) + assert.False(t, stored.Cleared) assert.Equal(t, "banner-command-42", stored.EventID) } + +func TestClearBeforeMaterialize(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + useCommandContexts(t) + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, ApplyCommand(clearCommand(t, inst, 2))) + cleared := storedState(t, inst) + require.True(t, cleared.Cleared) + require.NotNil(t, cleared.EndsAt) + assert.True(t, cleared.EndsAt.Before(time.Now())) + require.NoError(t, ApplyCommand(materialize(t, inst, 1))) + assert.Equal(t, cleared.DocRev, storedState(t, inst).DocRev) + require.NoError(t, ApplyCommand(materialize(t, inst, 3))) + require.NotNil(t, storedBanner(t, inst)) + assert.False(t, storedState(t, inst).Cleared) +} diff --git a/model/banner/materialize.go b/model/banner/materialize.go index 073a18856f0..e825efbed02 100644 --- a/model/banner/materialize.go +++ b/model/banner/materialize.go @@ -1,6 +1,7 @@ package banner import ( + "reflect" "time" "github.com/cozy/cozy-stack/pkg/consts" @@ -37,7 +38,7 @@ func Merge(fresh, stored *Banner) *Banner { } merged.DocID = stored.DocID merged.DocRev = stored.DocRev - if stored.BannerID == fresh.BannerID { + if !stored.Cleared && !fresh.Cleared && stored.BannerID == fresh.BannerID { merged.DismissedAt = stored.DismissedAt // Carrying the start forward must not invert a window the producer // just shortened to end before the occurrence began. @@ -134,7 +135,11 @@ func stamp(b *Banner, now time.Time) { // and DismissedAt are excluded because Merge takes them from the stored // document, and Source.At because it moves on every evaluation by design. func changed(fresh, stored *Banner) bool { - return fresh.BannerID != stored.BannerID || + return fresh.Revision != stored.Revision || + fresh.EventID != stored.EventID || + fresh.Cleared != stored.Cleared || + !reflect.DeepEqual(fresh.Accepted, stored.Accepted) || + fresh.BannerID != stored.BannerID || fresh.Category != stored.Category || fresh.Severity != stored.Severity || fresh.Surface != stored.Surface || @@ -177,7 +182,8 @@ func ctaChanged(fresh, stored *CTA) bool { } // Stored returns the banner materialized for a category, or nil when there is -// none. A missing database is the first call on a fresh instance. +// none. Cleared commands remain as expired documents to retain ordering. +// A missing database is the first call on a fresh instance. func Stored(db prefixer.Prefixer, category string) (*Banner, error) { var doc Banner err := couchdb.GetDoc(db, consts.Banners, docID(category), &doc) diff --git a/model/banner/trigger.go b/model/banner/trigger.go index 3d0f1fd4234..1742be977f5 100644 --- a/model/banner/trigger.go +++ b/model/banner/trigger.go @@ -7,6 +7,8 @@ import ( "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/model/vfs" "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/logger" ) @@ -123,16 +125,19 @@ func refreshCommandsAt(domain string) error { // ponytail: one _all_docs read per instance patch, including the quota // patches that share this hook and never need it. Split the hook by reason // if that read ever shows up. - states, err := storedCommands(inst) + var states []*Banner + err = couchdb.GetAllDocs(inst, consts.Banners, nil, &states) + if couchdb.IsNoDatabaseError(err) { + return nil + } if err != nil { return err } now := time.Now() for _, state := range states { - // A cleared category holds no document, and a record from before the - // stack retained the wording has nothing to pick from. - if state.Clear || state.Accepted == nil { + // Clears and banners without a retained command need no localization. + if state.Cleared || state.Accepted == nil { continue } // A category the context has stopped accepting is left alone rather @@ -140,18 +145,6 @@ func refreshCommandsAt(domain string) error { if !inst.AllowsBannerCategory(state.Category) { continue } - // Re-localizing rewrites a banner, it does not restore one. The - // retained command carries the wording, not the occurrence's own - // start, which lives on the document an application is allowed to - // delete; recreating from the command alone would move a scheduled - // banner to the decision time of whichever command wrote last. - current, err := Stored(inst, state.Category) - if err != nil { - return err - } - if current == nil { - continue - } if err := Materialize(inst, state.Category, state.Accepted.banner(inst.Locale), now); err != nil { return err } diff --git a/model/permission/doctype.go b/model/permission/doctype.go index 020263cde98..e2869190a85 100644 --- a/model/permission/doctype.go +++ b/model/permission/doctype.go @@ -21,7 +21,6 @@ var blockList = map[string]bool{ consts.RemoteSecrets: none, // Only stack can manipulate them - consts.BannerCommands: none, consts.Sessions: none, consts.Permissions: none, consts.Intents: none, diff --git a/pkg/consts/doctype.go b/pkg/consts/doctype.go index 2bf97dfabab..de008f18ba6 100644 --- a/pkg/consts/doctype.go +++ b/pkg/consts/doctype.go @@ -18,9 +18,6 @@ const ( AppLogs = "io.cozy.apps.logs" // Banners doc type for the platform messages displayed to the user Banners = "io.cozy.banners" - // BannerCommands stores the last accepted command per category, separate - // from Banners so an app with dismissal access cannot rewrite the ordering. - BannerCommands = "io.cozy.banners.commands" // Konnectors doc type for konnector application manifests Konnectors = "io.cozy.konnectors" // KonnectorsMaintenance doc type for maintenance of konnectors. diff --git a/pkg/rabbitmq/banner_commands_test.go b/pkg/rabbitmq/banner_commands_test.go index 3a920cd1146..9433fd289c2 100644 --- a/pkg/rabbitmq/banner_commands_test.go +++ b/pkg/rabbitmq/banner_commands_test.go @@ -136,9 +136,9 @@ func TestBannerCommandsThroughTheBroker(t *testing.T) { assert.Equal(t, "billing.grace.cycle-a.attempt-2", stored().BannerID) }) - t.Run("a clear removes it, and a redelivered older command does not bring it back", func(t *testing.T) { + t.Run("a clear expires it, and a redelivered older command does not bring it back", func(t *testing.T) { publish(t, rabbitmq.RoutingKeyBannerClear, command(t, "clear", 2, nil)) - testutils.WaitForOrFail(t, 20*time.Second, func() bool { return stored() == nil }) + testutils.WaitForOrFail(t, 20*time.Second, func() bool { b := stored(); return b != nil && b.Cleared }) publish(t, rabbitmq.RoutingKeyBannerMaterialize, command(t, "materialize", 1, nil)) // The queue processes serially. A rejected marker proves the stale @@ -147,7 +147,10 @@ func TestBannerCommandsThroughTheBroker(t *testing.T) { dead, ok := testutils.GetOneFromQueue(t, MQ, dlqName, 30*time.Second) require.True(t, ok) require.Contains(t, string(dead.Body), "stale-replay-barrier") - assert.Nil(t, stored(), "a stale delivery must leave the category cleared") + require.NotNil(t, stored()) + assert.True(t, stored().Cleared, "a stale delivery must leave the category cleared") + require.NotNil(t, stored().EndsAt) + assert.True(t, stored().EndsAt.Before(time.Now())) }) t.Run("a malformed command reaches the dead letter queue", func(t *testing.T) { @@ -159,15 +162,22 @@ func TestBannerCommandsThroughTheBroker(t *testing.T) { assertDeadLettered(t, dead) }) - t.Run("a command for a category the context refuses reaches the dead letter queue", func(t *testing.T) { + t.Run("a command for a category the context refuses is skipped", func(t *testing.T) { publish(t, rabbitmq.RoutingKeyBannerMaterialize, command(t, "materialize", 4, func(p map[string]interface{}) { p["category"] = "trial" })) + // A marker proves the preceding command was processed and skipped. + publish(t, rabbitmq.RoutingKeyBannerMaterialize, []byte(`{"eventId":"skipped-category-barrier"}`)) dead, ok := testutils.GetOneFromQueue(t, MQ, dlqName, 30*time.Second) require.True(t, ok) - assert.Contains(t, string(dead.Body), `"category":"trial"`) - assertDeadLettered(t, dead) + assert.Contains(t, string(dead.Body), "skipped-category-barrier") + inst, err := lifecycle.GetInstance(domain) + require.NoError(t, err) + trial, err := banner.Stored(inst, banner.CategoryTrial) + require.NoError(t, err) + assert.Nil(t, trial) + }) } diff --git a/pkg/rabbitmq/handlers_test.go b/pkg/rabbitmq/handlers_test.go index 1256e3e34c9..0c053236c37 100644 --- a/pkg/rabbitmq/handlers_test.go +++ b/pkg/rabbitmq/handlers_test.go @@ -157,7 +157,10 @@ func TestBannerCommandHandler(t *testing.T) { require.NotNil(t, stored(t)) require.NoError(t, handle(t, rabbitmq.RoutingKeyBannerClear, fixture(t, "clear", 2))) - require.Nil(t, stored(t)) + require.NotNil(t, stored(t)) + require.True(t, stored(t).Cleared) + require.NotNil(t, stored(t).EndsAt) + assert.True(t, stored(t).EndsAt.Before(time.Now())) }) t.Run("a payload that does not parse fails", func(t *testing.T) { diff --git a/web/data/banner_commands_test.go b/web/data/banner_commands_test.go index fc63c43ae77..b10cd71e69a 100644 --- a/web/data/banner_commands_test.go +++ b/web/data/banner_commands_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestBannerCommandStateIsPrivate(t *testing.T) { +func TestBannerDismissalPreservesCommandState(t *testing.T) { if testing.Short() { t.Skip("requires CouchDB") } @@ -20,50 +20,24 @@ func TestBannerCommandStateIsPrivate(t *testing.T) { setup := testutils.NewSetup(t, t.Name()) inst := setup.GetTestInstance() ts := setup.GetTestServer("/data", Routes) - t.Cleanup(ts.Close) - state := &couchdb.JSONDoc{Type: consts.BannerCommands, M: M{"_id": "banner-billing", "revision": 42}} - require.NoError(t, couchdb.CreateNamedDocWithDB(inst, state)) - public := &couchdb.JSONDoc{Type: consts.Banners, M: M{"_id": "banner-billing", "dismissedAt": nil}} + public := &couchdb.JSONDoc{Type: consts.Banners, M: M{ + "_id": "banner-billing", "revision": 42, "dismissedAt": nil, + "accepted": M{"text": M{"en": "Payment failed", "fr": "Échec du paiement"}}, + }} require.NoError(t, couchdb.CreateNamedDocWithDB(inst, public)) - - for _, scope := range []string{consts.BannerCommands + " " + consts.Banners, "io.cozy.banners.*"} { - t.Run(scope, func(t *testing.T) { - _, token := setup.GetTestClient(scope) - e := testutils.CreateTestClient(t, ts.URL) - path := "/data/" + consts.BannerCommands - for _, req := range []struct { - method, suffix string - body interface{} - }{ - {"GET", "/banner-billing", nil}, - {"PUT", "/banner-billing", state.M}, - {"PUT", "/new-command", M{"revision": 999}}, - {"POST", "/", M{"revision": 999}}, - {"DELETE", "/banner-billing", nil}, - {"DELETE", "/", nil}, - {"GET", "/_all_docs", nil}, - {"POST", "/_find", M{"selector": M{}}}, - {"POST", "/_bulk_docs", M{"docs": []interface{}{state.M}}}, - {"POST", "/_bulk_get", M{"docs": []interface{}{M{"id": state.ID()}}}}, - {"GET", "/_changes", nil}, - } { - r := e.Request(req.method, path+req.suffix).WithHeader("Authorization", "Bearer "+token) - if req.method == "DELETE" && req.suffix == "/banner-billing" { - r.WithQuery("rev", state.Rev()) - } - if req.body != nil { - r.WithJSON(req.body) - } - r.Expect().Status(403) - } - // Reserving the internal state must not remove public dismissal access. - require.NoError(t, couchdb.GetDoc(inst, consts.Banners, public.ID(), public)) - public.M["dismissedAt"] = time.Now().UTC().Format(time.RFC3339) - e.PUT("/data/"+consts.Banners+"/"+public.ID()). - WithHeader("Authorization", "Bearer "+token).WithJSON(public.M).Expect().Status(200) - }) - } - var unchanged couchdb.JSONDoc - require.NoError(t, couchdb.GetDoc(inst, consts.BannerCommands, state.ID(), &unchanged)) - require.Equal(t, state.Rev(), unchanged.Rev()) + _, token := setup.GetTestClient(consts.Banners) + e := testutils.CreateTestClient(t, ts.URL) + path := "/data/" + consts.Banners + "/" + public.ID() + e.GET(path).WithHeader("Authorization", "Bearer "+token).Expect().Status(200) + previousRev := public.Rev() + public.M["dismissedAt"] = time.Now().UTC().Format(time.RFC3339) + e.PUT(path).WithHeader("Authorization", "Bearer "+token).WithJSON(public.M).Expect().Status(200) + // The normal CouchDB revision check rejects a stale dismissal write. + public.SetRev(previousRev) + e.PUT(path).WithHeader("Authorization", "Bearer "+token).WithJSON(public.M).Expect().Status(409) + var stored couchdb.JSONDoc + require.NoError(t, couchdb.GetDoc(inst, consts.Banners, public.ID(), &stored)) + require.EqualValues(t, 42, stored.M["revision"]) + require.Equal(t, public.M["dismissedAt"], stored.M["dismissedAt"]) + require.Equal(t, "Échec du paiement", stored.M["accepted"].(map[string]interface{})["text"].(map[string]interface{})["fr"]) } From 3a9328040982fac1b878f0bb98efa1083980a17c Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 12:31:31 +0100 Subject: [PATCH 11/17] fix: update banner integration tests to use shared setup --- model/banner/banner_test.go | 28 -- model/banner/command_integration_test.go | 596 +++++++++++++++++++++++ model/banner/command_io_test.go | 26 +- model/banner/command_test.go | 564 --------------------- 4 files changed, 610 insertions(+), 604 deletions(-) create mode 100644 model/banner/command_integration_test.go diff --git a/model/banner/banner_test.go b/model/banner/banner_test.go index ac0b3f8a099..51739167c43 100644 --- a/model/banner/banner_test.go +++ b/model/banner/banner_test.go @@ -1,42 +1,14 @@ package banner import ( - "context" - "os" "testing" "time" - "github.com/cozy/cozy-stack/pkg/config/config" - "github.com/cozy/cozy-stack/pkg/couchdb" - "github.com/cozy/cozy-stack/pkg/i18n" "github.com/cozy/cozy-stack/pkg/metadata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestMain loads the real catalogs so a stale message id breaks the test, not -// production, and brings up the global database (inlined from testutils to -// avoid a circular import). -func TestMain(m *testing.M) { - for _, locale := range []string{"en", "fr"} { - po, err := os.ReadFile("../../assets/locales/" + locale + ".po") - if err != nil { - panic(err) - } - i18n.LoadLocale(locale, "", po) - } - if err := config.LoadTestFile(); err != nil { - panic(err) - } - ctx := context.Background() - if _, err := couchdb.CheckStatus(ctx); err == nil { - if err := couchdb.InitGlobalDB(ctx); err != nil { - panic(err) - } - } - os.Exit(m.Run()) -} - var now = time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) const gigabyte = 1000 * 1000 * 1000 diff --git a/model/banner/command_integration_test.go b/model/banner/command_integration_test.go new file mode 100644 index 00000000000..7d2885556dc --- /dev/null +++ b/model/banner/command_integration_test.go @@ -0,0 +1,596 @@ +package banner_test + +import ( + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/cozy/cozy-stack/model/banner" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/i18n" + "github.com/cozy/cozy-stack/pkg/prefixer" + "github.com/cozy/cozy-stack/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Load the real catalogs, then use the shared database setup. +func TestMain(m *testing.M) { + for _, locale := range []string{"en", "fr"} { + po, err := os.ReadFile("../../assets/locales/" + locale + ".po") + if err != nil { + panic(err) + } + i18n.LoadLocale(locale, "", po) + } + os.Exit(testutils.RunTestMainWithCouchDB(m)) +} + +func fixture(t *testing.T, name string) banner.Command { + t.Helper() + raw, err := os.ReadFile("testdata/" + name + ".json") + require.NoError(t, err) + var cmd banner.Command + require.NoError(t, json.Unmarshal(raw, &cmd)) + return cmd +} + +var now = time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) + +const gigabyte = 1000 * 1000 * 1000 + +const ( + commandContext = "banner-command-test" + refusedContext = "banner-command-test-refused" + noBannerContext = "banner-command-test-off" +) + +// useCommandContexts registers the test contexts used by the command tests. +func useCommandContexts(t *testing.T) { + t.Helper() + conf := config.GetConfig() + previous := conf.Contexts + conf.Contexts = map[string]interface{}{ + commandContext: map[string]interface{}{ + "enable_banners": true, + "banner_command_categories": []interface{}{banner.CategoryBilling, banner.CategoryTrial}, + }, + refusedContext: map[string]interface{}{ + "enable_banners": true, + }, + noBannerContext: map[string]interface{}{}, + } + t.Cleanup(func() { conf.Contexts = previous }) +} + +func newInstance(t *testing.T, contextName, locale, orgID string) *instance.Instance { + t.Helper() + domain := fmt.Sprintf("banner-cmd-%d.example", time.Now().UnixNano()) + inst, err := lifecycle.Create(&lifecycle.Options{ + Domain: domain, + Email: "alice@example.org", + Locale: locale, + ContextName: contextName, + OrgID: orgID, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) + return inst +} + +// materialize returns the shared fixture aimed at one instance. Every command +// keeps the fixture's timestamp, so only the revision orders them. +func materialize(t *testing.T, inst *instance.Instance, revision int64) banner.Command { + t.Helper() + cmd := fixture(t, "materialize") + cmd.WorkplaceFqdn = inst.Domain + cmd.Revision = revision + return cmd +} + +func clearCommand(t *testing.T, inst *instance.Instance, revision int64) banner.Command { + t.Helper() + cmd := fixture(t, "clear") + cmd.WorkplaceFqdn = inst.Domain + cmd.Revision = revision + cmd.Clear = true + return cmd +} + +func storedBanner(t *testing.T, inst *instance.Instance) *banner.Banner { + t.Helper() + stored, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + if stored != nil && stored.Cleared { + return nil + } + return stored +} + +func storedState(t *testing.T, inst *instance.Instance) *banner.Banner { + t.Helper() + stored, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + require.NotNil(t, stored) + return stored +} + +// dismiss records a dismissal the way an application does, by writing the +// public document. +func dismiss(t *testing.T, inst *instance.Instance) { + t.Helper() + stored := storedBanner(t, inst) + require.NotNil(t, stored) + at := time.Now().UTC().Truncate(time.Second) + stored.DismissedAt = &at + require.NoError(t, couchdb.UpdateDoc(inst, stored)) +} + +func TestApplyCommand(t *testing.T) { + config.UseTestFile(t) + testutils.NeedCouchdb(t) + useCommandContexts(t) + + t.Run("a materialize creates the document a client reads", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 42))) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "banner-billing", stored.DocID, "one document per category") + assert.Equal(t, "billing.grace.cycle-a.attempt-2", stored.BannerID) + assert.Equal(t, "stack", stored.Metadata.CreatedByApp, "clients gate trust on this") + assert.Equal(t, banner.DocTypeVersion, stored.Metadata.DocTypeVersion) + assert.Equal(t, banner.TriggerCommand, stored.Source.Trigger) + }) + + t.Run("an unchanged newer decision persists its ordering", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 10))) + created := storedBanner(t, inst) + require.NotNil(t, created) + + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 11))) + again := storedBanner(t, inst) + require.NotNil(t, again) + assert.NotEqual(t, created.DocRev, again.DocRev) + assert.Equal(t, int64(11), again.Revision) + + // The stale clear is what the old timestamp guard let through: the + // document it would compare against never moved. + require.NoError(t, banner.ApplyCommand(clearCommand(t, inst, 10))) + assert.NotNil(t, storedBanner(t, inst), "a clear older than the last decision changes nothing") + }) + + t.Run("a newer translation is retained even when the displayed language is unchanged", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + original := materialize(t, inst, 12) + require.NoError(t, banner.ApplyCommand(original)) + updated := materialize(t, inst, 13) + updated.Text["fr"] = "Veuillez vérifier votre carte." + require.NoError(t, banner.ApplyCommand(updated)) + assert.Equal(t, original.Text["en"], storedBanner(t, inst).Text) + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "fr"})) + assert.Equal(t, updated.Text["fr"], storedBanner(t, inst).Text) + }) + + t.Run("a clear expires the document and outlives a stale materialize", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 20))) + require.NotNil(t, storedBanner(t, inst)) + + require.NoError(t, banner.ApplyCommand(clearCommand(t, inst, 21))) + assert.Nil(t, storedBanner(t, inst)) + + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 20))) + assert.Nil(t, storedBanner(t, inst), "the expired document keeps the cleared revision") + }) + + t.Run("materializing after a clear starts a fresh occurrence", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 22))) + dismiss(t, inst) + require.NoError(t, banner.ApplyCommand(clearCommand(t, inst, 23))) + cleared := storedState(t, inst) + require.True(t, cleared.Cleared) + assert.Nil(t, cleared.Accepted) + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 24))) + require.NotNil(t, storedBanner(t, inst)) + assert.Nil(t, storedBanner(t, inst).DismissedAt) + }) + + t.Run("a redelivery of the same revision changes nothing", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 30))) + first := storedBanner(t, inst) + require.NotNil(t, first) + + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 30))) + again := storedBanner(t, inst) + require.NotNil(t, again) + assert.Equal(t, first.DocRev, again.DocRev) + }) + + // A revision reused with different wording is a backend bug the stack + // cannot repair, so it is ignored like any other non-newer revision rather + // than given a rejection path of its own. + t.Run("a revision reused for another payload is ignored", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 40))) + + other := materialize(t, inst, 40) + other.BannerID = "billing.restricted" + require.NoError(t, banner.ApplyCommand(other)) + assert.Equal(t, "billing.grace.cycle-a.attempt-2", storedBanner(t, inst).BannerID) + }) + + t.Run("the same occurrence keeps a dismissal the user recorded", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 50))) + + dismissed := storedBanner(t, inst) + require.NotNil(t, dismissed) + at := time.Now().UTC().Truncate(time.Second) + dismissed.DismissedAt = &at + require.NoError(t, couchdb.UpdateDoc(inst, dismissed)) + + // A redelivery of the same revision, then a newer command with new + // wording for the same occurrence. + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 50))) + require.NotNil(t, storedBanner(t, inst).DismissedAt, "a retry must not resurrect a closed banner") + + reworded := materialize(t, inst, 51) + reworded.Text["en"] = "We could not charge your card. This is the last attempt." + require.NoError(t, banner.ApplyCommand(reworded)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Contains(t, stored.Text, "last attempt") + require.NotNil(t, stored.DismissedAt, "same occurrence, same dismissal") + + // A new occurrence is a message the user has not seen. + escalated := materialize(t, inst, 52) + escalated.BannerID = "billing.grace.cycle-a.attempt-3" + require.NoError(t, banner.ApplyCommand(escalated)) + assert.Nil(t, storedBanner(t, inst).DismissedAt) + }) + + t.Run("the instance locale decides the wording", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 60))) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "fr", stored.Lang) + assert.Equal(t, "Échec du paiement", stored.Title) + }) + + t.Run("a refresh re-localizes a banner but does not restore a deleted one", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + scheduled := materialize(t, inst, 76) + starts := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) + ends := time.Date(2027, 3, 2, 0, 0, 0, 0, time.UTC) + scheduled.StartsAt, scheduled.EndsAt = &starts, &ends + require.NoError(t, banner.ApplyCommand(scheduled)) + + // A later decision on the same occurrence that states no window: the + // scheduled start now lives only on the public document. + reworded := materialize(t, inst, 77) + reworded.StartsAt, reworded.EndsAt = nil, &ends + require.NoError(t, banner.ApplyCommand(reworded)) + require.Equal(t, starts, storedBanner(t, inst).StartsAt.UTC()) + + // An application deletes it, as its write access lets it. + require.NoError(t, couchdb.DeleteDoc(inst, storedBanner(t, inst))) + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) + + assert.Nil(t, storedBanner(t, inst), + "recreating it would move a March 2027 banner to the decision time") + }) + + t.Run("a language change re-picks a locale the backend already sent", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 61))) + require.Equal(t, "fr", storedBanner(t, inst).Lang) + + // The stack keeps every locale the backend sent, so it can pick again + // without the backend publishing anything. + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "en", stored.Lang) + assert.Equal(t, "Payment failed", stored.Title) + assert.Equal(t, int64(61), storedState(t, inst).Revision, "re-localizing is not a decision") + }) + + t.Run("a language change falls back for a locale the backend did not send", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 62))) + + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "de"})) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, consts.DefaultLocale, stored.Lang) + }) + + t.Run("a language change keeps a dismissal and a cleared category", func(t *testing.T) { + inst := newInstance(t, commandContext, "fr", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 63))) + dismiss(t, inst) + + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.Equal(t, "en", stored.Lang) + require.NotNil(t, stored.DismissedAt, "a new language is not a new occurrence") + + require.NoError(t, banner.ApplyCommand(clearCommand(t, inst, 64))) + require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "fr"})) + assert.Nil(t, storedBanner(t, inst), "a cleared category stays cleared") + }) + + t.Run("a moved window is applied at both ends", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 70))) + + // The same occurrence, moved wholesale into the next year. Applying + // only the new end would leave a window the backend never asked for. + moved := materialize(t, inst, 71) + starts := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) + ends := time.Date(2027, 3, 10, 0, 0, 0, 0, time.UTC) + moved.StartsAt, moved.EndsAt = &starts, &ends + require.NoError(t, banner.ApplyCommand(moved)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + require.NotNil(t, stored.StartsAt) + require.NotNil(t, stored.EndsAt) + assert.Equal(t, starts, stored.StartsAt.UTC()) + assert.Equal(t, ends, stored.EndsAt.UTC()) + }) + + t.Run("a command that states no window keeps the occurrence's start", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 72))) + began := storedBanner(t, inst).StartsAt + require.NotNil(t, began) + + reworded := materialize(t, inst, 73) + reworded.StartsAt, reworded.EndsAt = nil, nil + reworded.Text["en"] = "We could not charge your card. This is the last attempt." + require.NoError(t, banner.ApplyCommand(reworded)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + require.NotNil(t, stored.StartsAt) + assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), + "rewording an occurrence must not restart it") + }) + + t.Run("an end moved on its own is accepted, even into the past", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 74))) + began := storedBanner(t, inst).StartsAt + require.NotNil(t, began) + + // The backend closes the window without restating the start. Judging + // this at intake against the decision time would refuse it. + ended := materialize(t, inst, 75) + ended.Timestamp = time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC).Unix() + ends := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + ended.StartsAt, ended.EndsAt = nil, &ends + require.NoError(t, banner.ApplyCommand(ended)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + require.NotNil(t, stored.EndsAt) + assert.Equal(t, ends, stored.EndsAt.UTC()) + require.NotNil(t, stored.StartsAt) + assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), "the occurrence keeps its own start") + }) + + t.Run("a category the context does not accept is skipped", func(t *testing.T) { + inst := newInstance(t, refusedContext, "en", "") + + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 80))) + assert.Nil(t, storedBanner(t, inst)) + stored, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + assert.Nil(t, stored, "skipping must not advance the revision") + }) + + t.Run("an instance that displays no banner is a no-op", func(t *testing.T) { + inst := newInstance(t, noBannerContext, "en", "") + + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 90))) + assert.Nil(t, storedBanner(t, inst)) + }) + + t.Run("an unknown workplace is retried, not rejected", func(t *testing.T) { + cmd := fixture(t, "materialize") + cmd.WorkplaceFqdn = fmt.Sprintf("missing-%d.example", time.Now().UnixNano()) + + err := banner.ApplyCommand(cmd) + require.Error(t, err) + assert.NotErrorIs(t, err, banner.ErrInvalidCommand, + "a workplace still being provisioned is indistinguishable from a deleted one") + }) + + t.Run("an invalid command never reaches an instance", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + cmd := materialize(t, inst, 100) + cmd.Severity = "critical" + + assert.ErrorIs(t, banner.ApplyCommand(cmd), banner.ErrInvalidCommand) + assert.Nil(t, storedBanner(t, inst)) + }) + + t.Run("a blocking modal with no way out is made closable", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + cmd := materialize(t, inst, 105) + cmd.Surface = banner.SurfaceModal + cmd.Dismissible = false + cmd.CTA, cmd.SecondaryCTA = nil, nil + require.NoError(t, banner.ApplyCommand(cmd)) + + stored := storedBanner(t, inst) + require.NotNil(t, stored) + assert.True(t, stored.Dismissible, "a reload is not a way out, it brings the same banner back") + }) + + t.Run("a commanded banner and a quota banner coexist", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + + quota := banner.EvaluateQuota(banner.QuotaState{Used: 10 * gigabyte, Quota: 10 * gigabyte}, now) + require.NoError(t, banner.Materialize(inst, banner.CategoryQuota, quota, now)) + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 110))) + + fromRules, err := banner.Stored(inst, banner.CategoryQuota) + require.NoError(t, err) + require.NotNil(t, fromRules) + assert.Equal(t, banner.BannerIDQuotaExceeded, fromRules.BannerID) + require.NotNil(t, fromRules.StartsAt, "startsAt is not a field a client may find missing") + assert.Equal(t, now, *fromRules.StartsAt) + require.NotNil(t, storedBanner(t, inst)) + + // And the quota slot stays the stack's own, whatever the queue says. + fromQueue := materialize(t, inst, 111) + fromQueue.Category = banner.CategoryQuota + assert.ErrorIs(t, banner.ApplyCommand(fromQueue), banner.ErrInvalidCommand) + fromRules, err = banner.Stored(inst, banner.CategoryQuota) + require.NoError(t, err) + require.NotNil(t, fromRules) + assert.Equal(t, banner.BannerIDQuotaExceeded, fromRules.BannerID) + }) +} + +func TestApplyCommandToAnOrganization(t *testing.T) { + config.UseTestFile(t) + testutils.NeedCouchdb(t) + useCommandContexts(t) + + orgCommand := func(t *testing.T, orgID string, revision int64) banner.Command { + t.Helper() + cmd := fixture(t, "organization") + cmd.Tenant = orgID + cmd.Revision = revision + return cmd + } + + t.Run("every instance of the organization gets the banner", func(t *testing.T) { + orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) + first := newInstance(t, commandContext, "en", orgID) + second := newInstance(t, commandContext, "fr", orgID) + + other := newInstance(t, commandContext, "en", orgID+"-other") + other.OrgDomain = orgID + require.NoError(t, couchdb.UpdateDoc(prefixer.GlobalPrefixer, other)) + + require.NoError(t, banner.ApplyCommand(orgCommand(t, orgID, 7))) + + for _, inst := range []*instance.Instance{first, second} { + stored := storedBanner(t, inst) + require.NotNil(t, stored, inst.Domain) + assert.Equal(t, "billing.restricted", stored.BannerID) + } + assert.Equal(t, "fr", storedBanner(t, second).Lang, "each member reads its own language") + assert.Nil(t, storedBanner(t, other), "a matching organization domain must not select another tenant") + + clear := orgCommand(t, orgID, 8) + clear = banner.Command{Category: clear.Category, Tenant: clear.Tenant, Revision: clear.Revision, Timestamp: clear.Timestamp, Clear: true} + require.NoError(t, banner.ApplyCommand(clear)) + assert.Nil(t, storedBanner(t, first)) + assert.Nil(t, storedBanner(t, second)) + }) + + t.Run("a replay reaches a member provisioned after the command", func(t *testing.T) { + orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) + first := newInstance(t, commandContext, "en", orgID) + require.NoError(t, banner.ApplyCommand(orgCommand(t, orgID, 7))) + before := storedBanner(t, first) + require.NotNil(t, before) + + joined := newInstance(t, commandContext, "en", orgID) + require.NoError(t, banner.ApplyCommand(orgCommand(t, orgID, 7))) + + require.NotNil(t, storedBanner(t, joined), "an equal revision resolves membership again") + assert.Equal(t, before.DocRev, storedBanner(t, first).DocRev, "and leaves the members it already reached alone") + }) + + t.Run("a disallowed category skips only that instance", func(t *testing.T) { + orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) + accepting := newInstance(t, commandContext, "en", orgID) + refusing := newInstance(t, refusedContext, "en", orgID) + + require.NoError(t, banner.ApplyCommand(orgCommand(t, orgID, 7))) + before := storedBanner(t, accepting) + require.NotNil(t, before) + assert.Nil(t, storedBanner(t, refusing)) + stored, err := banner.Stored(refusing, banner.CategoryBilling) + require.NoError(t, err) + assert.Nil(t, stored, "skipping must not advance the member's revision") + + conf := config.GetConfig() + conf.Contexts[refusedContext] = map[string]interface{}{ + "enable_banners": true, + "banner_command_categories": []interface{}{banner.CategoryBilling}, + } + t.Cleanup(func() { + conf.Contexts[refusedContext] = map[string]interface{}{"enable_banners": true} + }) + + // Replay reaches a previously skipped member after its context allows + // the category, without changing members that already accepted it. + require.NoError(t, banner.ApplyCommand(orgCommand(t, orgID, 7))) + assert.Equal(t, before.DocRev, storedBanner(t, accepting).DocRev) + assert.NotNil(t, storedBanner(t, refusing)) + }) + + t.Run("an organization with no instance is a no-op", func(t *testing.T) { + assert.NoError(t, banner.ApplyCommand(orgCommand(t, fmt.Sprintf("empty-org-%d", time.Now().UnixNano()), 7))) + }) +} + +func TestCommandStateSharesTheBannerDocument(t *testing.T) { + config.UseTestFile(t) + testutils.NeedCouchdb(t) + useCommandContexts(t) + + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 42))) + + stored, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, consts.Banners, stored.DocType()) + assert.Equal(t, int64(42), stored.Revision) + assert.False(t, stored.Cleared) + assert.Equal(t, "banner-command-42", stored.EventID) +} + +func TestClearBeforeMaterialize(t *testing.T) { + config.UseTestFile(t) + testutils.NeedCouchdb(t) + useCommandContexts(t) + inst := newInstance(t, commandContext, "en", "") + require.NoError(t, banner.ApplyCommand(clearCommand(t, inst, 2))) + cleared := storedState(t, inst) + require.True(t, cleared.Cleared) + require.NotNil(t, cleared.EndsAt) + assert.True(t, cleared.EndsAt.Before(time.Now())) + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 1))) + assert.Equal(t, cleared.DocRev, storedState(t, inst).DocRev) + require.NoError(t, banner.ApplyCommand(materialize(t, inst, 3))) + require.NotNil(t, storedBanner(t, inst)) + assert.False(t, storedState(t, inst).Cleared) +} diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go index b70d3f79790..239d8994e24 100644 --- a/model/banner/command_io_test.go +++ b/model/banner/command_io_test.go @@ -1,4 +1,4 @@ -package banner +package banner_test import ( "errors" @@ -7,11 +7,13 @@ import ( "sync/atomic" "testing" + "github.com/cozy/cozy-stack/model/banner" "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/prefixer" + "github.com/cozy/cozy-stack/tests/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,7 +24,7 @@ func (f commandRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { config.UseTestFile(t) - needCouchDB(t) + testutils.NeedCouchdb(t) useCommandContexts(t) first := newInstance(t, commandContext, "en", "") org := "org-" + first.Domain @@ -48,17 +50,17 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { } return original.RoundTrip(r) }) - err = ApplyCommand(cmd) + err = banner.ApplyCommand(cmd) require.ErrorContains(t, err, "simulated projection outage") - assert.NotErrorIs(t, err, ErrInvalidCommand) + assert.NotErrorIs(t, err, banner.ErrInvalidCommand) before := storedBanner(t, members[0]) require.NotNil(t, before) assert.Nil(t, storedBanner(t, members[1])) assert.Nil(t, storedBanner(t, members[2])) - retained, err := Stored(members[1], CategoryBilling) + retained, err := banner.Stored(members[1], banner.CategoryBilling) require.NoError(t, err) require.Nil(t, retained, "nothing is recorded for a member whose banner was not written") - require.NoError(t, ApplyCommand(cmd)) + require.NoError(t, banner.ApplyCommand(cmd)) for _, inst := range members { require.NotNil(t, storedBanner(t, inst)) } @@ -67,11 +69,11 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { func TestCommandClearRetriesProjectionFailure(t *testing.T) { config.UseTestFile(t) - needCouchDB(t) + testutils.NeedCouchdb(t) useCommandContexts(t) inst := newInstance(t, commandContext, "en", "") old := materialize(t, inst, 1) - require.NoError(t, ApplyCommand(old)) + require.NoError(t, banner.ApplyCommand(old)) client := config.CouchClient() original := client.Transport t.Cleanup(func() { client.Transport = original }) @@ -83,12 +85,12 @@ func TestCommandClearRetriesProjectionFailure(t *testing.T) { return original.RoundTrip(r) }) clear := clearCommand(t, inst, 2) - require.ErrorContains(t, ApplyCommand(clear), "simulated clear outage") - retained, err := Stored(inst, CategoryBilling) + require.ErrorContains(t, banner.ApplyCommand(clear), "simulated clear outage") + retained, err := banner.Stored(inst, banner.CategoryBilling) require.NoError(t, err) require.False(t, retained.Cleared, "a failed clear must leave the previous decision intact") require.Equal(t, int64(1), retained.Revision) - require.NoError(t, ApplyCommand(old)) - require.NoError(t, ApplyCommand(clear)) + require.NoError(t, banner.ApplyCommand(old)) + require.NoError(t, banner.ApplyCommand(clear)) assert.Nil(t, storedBanner(t, inst)) } diff --git a/model/banner/command_test.go b/model/banner/command_test.go index b17be99b712..232f2448fe1 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -1,7 +1,6 @@ package banner import ( - "context" "encoding/json" "fmt" "os" @@ -9,12 +8,7 @@ import ( "testing" "time" - "github.com/cozy/cozy-stack/model/instance" - "github.com/cozy/cozy-stack/model/instance/lifecycle" - "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" - "github.com/cozy/cozy-stack/pkg/couchdb" - "github.com/cozy/cozy-stack/pkg/prefixer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -320,561 +314,3 @@ func TestLocaleIsPickedForTheWholeBanner(t *testing.T) { } }) } - -// needCouchDB is testutils.NeedCouchdb, inlined to avoid a circular import. -func needCouchDB(t *testing.T) { - t.Helper() - if _, err := couchdb.CheckStatus(context.Background()); err != nil { - t.Fatal("This test need couchdb to run.") - } -} - -const ( - commandContext = "banner-command-test" - refusedContext = "banner-command-test-refused" - noBannerContext = "banner-command-test-off" -) - -// useCommandContexts registers the test contexts used by the command tests. -func useCommandContexts(t *testing.T) { - t.Helper() - conf := config.GetConfig() - previous := conf.Contexts - conf.Contexts = map[string]interface{}{ - commandContext: map[string]interface{}{ - "enable_banners": true, - "banner_command_categories": []interface{}{CategoryBilling, CategoryTrial}, - }, - refusedContext: map[string]interface{}{ - "enable_banners": true, - }, - noBannerContext: map[string]interface{}{}, - } - t.Cleanup(func() { conf.Contexts = previous }) -} - -func newInstance(t *testing.T, contextName, locale, orgID string) *instance.Instance { - t.Helper() - domain := fmt.Sprintf("banner-cmd-%d.example", time.Now().UnixNano()) - inst, err := lifecycle.Create(&lifecycle.Options{ - Domain: domain, - Email: "alice@example.org", - Locale: locale, - ContextName: contextName, - OrgID: orgID, - }) - require.NoError(t, err) - t.Cleanup(func() { _ = lifecycle.Destroy(domain) }) - return inst -} - -// materialize returns the shared fixture aimed at one instance. Every command -// keeps the fixture's timestamp, so only the revision orders them. -func materialize(t *testing.T, inst *instance.Instance, revision int64) Command { - t.Helper() - cmd := fixture(t, "materialize") - cmd.WorkplaceFqdn = inst.Domain - cmd.Revision = revision - return cmd -} - -func clearCommand(t *testing.T, inst *instance.Instance, revision int64) Command { - t.Helper() - cmd := fixture(t, "clear") - cmd.WorkplaceFqdn = inst.Domain - cmd.Revision = revision - cmd.Clear = true - return cmd -} - -func storedBanner(t *testing.T, inst *instance.Instance) *Banner { - t.Helper() - stored, err := Stored(inst, CategoryBilling) - require.NoError(t, err) - if stored != nil && stored.Cleared { - return nil - } - return stored -} - -func storedState(t *testing.T, inst *instance.Instance) *Banner { - t.Helper() - stored, err := Stored(inst, CategoryBilling) - require.NoError(t, err) - require.NotNil(t, stored) - return stored -} - -// dismiss records a dismissal the way an application does, by writing the -// public document. -func dismiss(t *testing.T, inst *instance.Instance) { - t.Helper() - stored := storedBanner(t, inst) - require.NotNil(t, stored) - at := time.Now().UTC().Truncate(time.Second) - stored.DismissedAt = &at - require.NoError(t, couchdb.UpdateDoc(inst, stored)) -} - -func TestApplyCommand(t *testing.T) { - config.UseTestFile(t) - needCouchDB(t) - useCommandContexts(t) - - t.Run("a materialize creates the document a client reads", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - - require.NoError(t, ApplyCommand(materialize(t, inst, 42))) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.Equal(t, "banner-billing", stored.DocID, "one document per category") - assert.Equal(t, "billing.grace.cycle-a.attempt-2", stored.BannerID) - assert.Equal(t, stackAuthor, stored.Metadata.CreatedByApp, "clients gate trust on this") - assert.Equal(t, DocTypeVersion, stored.Metadata.DocTypeVersion) - assert.Equal(t, TriggerCommand, stored.Source.Trigger) - }) - - t.Run("an unchanged newer decision persists its ordering", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 10))) - created := storedBanner(t, inst) - require.NotNil(t, created) - - require.NoError(t, ApplyCommand(materialize(t, inst, 11))) - again := storedBanner(t, inst) - require.NotNil(t, again) - assert.NotEqual(t, created.DocRev, again.DocRev) - assert.Equal(t, int64(11), again.Revision) - - // The stale clear is what the old timestamp guard let through: the - // document it would compare against never moved. - require.NoError(t, ApplyCommand(clearCommand(t, inst, 10))) - assert.NotNil(t, storedBanner(t, inst), "a clear older than the last decision changes nothing") - }) - - t.Run("a newer translation is retained even when the displayed language is unchanged", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - original := materialize(t, inst, 12) - require.NoError(t, ApplyCommand(original)) - updated := materialize(t, inst, 13) - updated.Text["fr"] = "Veuillez vérifier votre carte." - require.NoError(t, ApplyCommand(updated)) - assert.Equal(t, original.Text["en"], storedBanner(t, inst).Text) - require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "fr"})) - assert.Equal(t, updated.Text["fr"], storedBanner(t, inst).Text) - }) - - t.Run("a clear expires the document and outlives a stale materialize", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 20))) - require.NotNil(t, storedBanner(t, inst)) - - require.NoError(t, ApplyCommand(clearCommand(t, inst, 21))) - assert.Nil(t, storedBanner(t, inst)) - - require.NoError(t, ApplyCommand(materialize(t, inst, 20))) - assert.Nil(t, storedBanner(t, inst), "the expired document keeps the cleared revision") - }) - - t.Run("materializing after a clear starts a fresh occurrence", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 22))) - dismiss(t, inst) - require.NoError(t, ApplyCommand(clearCommand(t, inst, 23))) - cleared := storedState(t, inst) - require.True(t, cleared.Cleared) - assert.Nil(t, cleared.Accepted) - require.NoError(t, ApplyCommand(materialize(t, inst, 24))) - require.NotNil(t, storedBanner(t, inst)) - assert.Nil(t, storedBanner(t, inst).DismissedAt) - }) - - t.Run("a redelivery of the same revision changes nothing", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 30))) - first := storedBanner(t, inst) - require.NotNil(t, first) - - require.NoError(t, ApplyCommand(materialize(t, inst, 30))) - again := storedBanner(t, inst) - require.NotNil(t, again) - assert.Equal(t, first.DocRev, again.DocRev) - }) - - // A revision reused with different wording is a backend bug the stack - // cannot repair, so it is ignored like any other non-newer revision rather - // than given a rejection path of its own. - t.Run("a revision reused for another payload is ignored", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 40))) - - other := materialize(t, inst, 40) - other.BannerID = "billing.restricted" - require.NoError(t, ApplyCommand(other)) - assert.Equal(t, "billing.grace.cycle-a.attempt-2", storedBanner(t, inst).BannerID) - }) - - t.Run("the same occurrence keeps a dismissal the user recorded", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 50))) - - dismissed := storedBanner(t, inst) - require.NotNil(t, dismissed) - at := time.Now().UTC().Truncate(time.Second) - dismissed.DismissedAt = &at - require.NoError(t, couchdb.UpdateDoc(inst, dismissed)) - - // A redelivery of the same revision, then a newer command with new - // wording for the same occurrence. - require.NoError(t, ApplyCommand(materialize(t, inst, 50))) - require.NotNil(t, storedBanner(t, inst).DismissedAt, "a retry must not resurrect a closed banner") - - reworded := materialize(t, inst, 51) - reworded.Text["en"] = "We could not charge your card. This is the last attempt." - require.NoError(t, ApplyCommand(reworded)) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.Contains(t, stored.Text, "last attempt") - require.NotNil(t, stored.DismissedAt, "same occurrence, same dismissal") - - // A new occurrence is a message the user has not seen. - escalated := materialize(t, inst, 52) - escalated.BannerID = "billing.grace.cycle-a.attempt-3" - require.NoError(t, ApplyCommand(escalated)) - assert.Nil(t, storedBanner(t, inst).DismissedAt) - }) - - t.Run("the instance locale decides the wording", func(t *testing.T) { - inst := newInstance(t, commandContext, "fr", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 60))) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.Equal(t, "fr", stored.Lang) - assert.Equal(t, "Échec du paiement", stored.Title) - }) - - t.Run("a refresh re-localizes a banner but does not restore a deleted one", func(t *testing.T) { - inst := newInstance(t, commandContext, "fr", "") - scheduled := materialize(t, inst, 76) - starts := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) - ends := time.Date(2027, 3, 2, 0, 0, 0, 0, time.UTC) - scheduled.StartsAt, scheduled.EndsAt = &starts, &ends - require.NoError(t, ApplyCommand(scheduled)) - - // A later decision on the same occurrence that states no window: the - // scheduled start now lives only on the public document. - reworded := materialize(t, inst, 77) - reworded.StartsAt, reworded.EndsAt = nil, &ends - require.NoError(t, ApplyCommand(reworded)) - require.Equal(t, starts, storedBanner(t, inst).StartsAt.UTC()) - - // An application deletes it, as its write access lets it. - require.NoError(t, couchdb.DeleteDoc(inst, storedBanner(t, inst))) - require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) - - assert.Nil(t, storedBanner(t, inst), - "recreating it would move a March 2027 banner to the decision time") - }) - - t.Run("a language change re-picks a locale the backend already sent", func(t *testing.T) { - inst := newInstance(t, commandContext, "fr", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 61))) - require.Equal(t, "fr", storedBanner(t, inst).Lang) - - // The stack keeps every locale the backend sent, so it can pick again - // without the backend publishing anything. - require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.Equal(t, "en", stored.Lang) - assert.Equal(t, "Payment failed", stored.Title) - assert.Equal(t, int64(61), storedState(t, inst).Revision, "re-localizing is not a decision") - }) - - t.Run("a language change falls back for a locale the backend did not send", func(t *testing.T) { - inst := newInstance(t, commandContext, "fr", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 62))) - - require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "de"})) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.Equal(t, consts.DefaultLocale, stored.Lang) - }) - - t.Run("a language change keeps a dismissal and a cleared category", func(t *testing.T) { - inst := newInstance(t, commandContext, "fr", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 63))) - dismiss(t, inst) - - require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "en"})) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.Equal(t, "en", stored.Lang) - require.NotNil(t, stored.DismissedAt, "a new language is not a new occurrence") - - require.NoError(t, ApplyCommand(clearCommand(t, inst, 64))) - require.NoError(t, lifecycle.Patch(inst, &lifecycle.Options{Locale: "fr"})) - assert.Nil(t, storedBanner(t, inst), "a cleared category stays cleared") - }) - - t.Run("a moved window is applied at both ends", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 70))) - - // The same occurrence, moved wholesale into the next year. Applying - // only the new end would leave a window the backend never asked for. - moved := materialize(t, inst, 71) - starts := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) - ends := time.Date(2027, 3, 10, 0, 0, 0, 0, time.UTC) - moved.StartsAt, moved.EndsAt = &starts, &ends - require.NoError(t, ApplyCommand(moved)) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - require.NotNil(t, stored.StartsAt) - require.NotNil(t, stored.EndsAt) - assert.Equal(t, starts, stored.StartsAt.UTC()) - assert.Equal(t, ends, stored.EndsAt.UTC()) - }) - - t.Run("a command that states no window keeps the occurrence's start", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 72))) - began := storedBanner(t, inst).StartsAt - require.NotNil(t, began) - - reworded := materialize(t, inst, 73) - reworded.StartsAt, reworded.EndsAt = nil, nil - reworded.Text["en"] = "We could not charge your card. This is the last attempt." - require.NoError(t, ApplyCommand(reworded)) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - require.NotNil(t, stored.StartsAt) - assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), - "rewording an occurrence must not restart it") - }) - - t.Run("an end moved on its own is accepted, even into the past", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 74))) - began := storedBanner(t, inst).StartsAt - require.NotNil(t, began) - - // The backend closes the window without restating the start. Judging - // this at intake against the decision time would refuse it. - ended := materialize(t, inst, 75) - ended.Timestamp = time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC).Unix() - ends := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - ended.StartsAt, ended.EndsAt = nil, &ends - require.NoError(t, ApplyCommand(ended)) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - require.NotNil(t, stored.EndsAt) - assert.Equal(t, ends, stored.EndsAt.UTC()) - require.NotNil(t, stored.StartsAt) - assert.Equal(t, began.UTC(), stored.StartsAt.UTC(), "the occurrence keeps its own start") - }) - - t.Run("a category the context does not accept is skipped", func(t *testing.T) { - inst := newInstance(t, refusedContext, "en", "") - - require.NoError(t, ApplyCommand(materialize(t, inst, 80))) - assert.Nil(t, storedBanner(t, inst)) - stored, err := Stored(inst, CategoryBilling) - require.NoError(t, err) - assert.Nil(t, stored, "skipping must not advance the revision") - }) - - t.Run("an instance that displays no banner is a no-op", func(t *testing.T) { - inst := newInstance(t, noBannerContext, "en", "") - - require.NoError(t, ApplyCommand(materialize(t, inst, 90))) - assert.Nil(t, storedBanner(t, inst)) - }) - - t.Run("an unknown workplace is retried, not rejected", func(t *testing.T) { - cmd := valid(t) - cmd.WorkplaceFqdn = fmt.Sprintf("missing-%d.example", time.Now().UnixNano()) - - err := ApplyCommand(cmd) - require.Error(t, err) - assert.NotErrorIs(t, err, ErrInvalidCommand, - "a workplace still being provisioned is indistinguishable from a deleted one") - }) - - t.Run("an invalid command never reaches an instance", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - cmd := materialize(t, inst, 100) - cmd.Severity = "critical" - - assert.ErrorIs(t, ApplyCommand(cmd), ErrInvalidCommand) - assert.Nil(t, storedBanner(t, inst)) - }) - - t.Run("a blocking modal with no way out is made closable", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - cmd := materialize(t, inst, 105) - cmd.Surface = SurfaceModal - cmd.Dismissible = false - cmd.CTA, cmd.SecondaryCTA = nil, nil - require.NoError(t, ApplyCommand(cmd)) - - stored := storedBanner(t, inst) - require.NotNil(t, stored) - assert.True(t, stored.Dismissible, "a reload is not a way out, it brings the same banner back") - }) - - t.Run("a commanded banner and a quota banner coexist", func(t *testing.T) { - inst := newInstance(t, commandContext, "en", "") - - quota := EvaluateQuota(QuotaState{Used: 10 * gigabyte, Quota: 10 * gigabyte}, now) - require.NoError(t, Materialize(inst, CategoryQuota, quota, now)) - require.NoError(t, ApplyCommand(materialize(t, inst, 110))) - - fromRules, err := Stored(inst, CategoryQuota) - require.NoError(t, err) - require.NotNil(t, fromRules) - assert.Equal(t, BannerIDQuotaExceeded, fromRules.BannerID) - require.NotNil(t, fromRules.StartsAt, "startsAt is not a field a client may find missing") - assert.Equal(t, now, *fromRules.StartsAt) - require.NotNil(t, storedBanner(t, inst)) - - // And the quota slot stays the stack's own, whatever the queue says. - fromQueue := materialize(t, inst, 111) - fromQueue.Category = CategoryQuota - assert.ErrorIs(t, ApplyCommand(fromQueue), ErrInvalidCommand) - fromRules, err = Stored(inst, CategoryQuota) - require.NoError(t, err) - require.NotNil(t, fromRules) - assert.Equal(t, BannerIDQuotaExceeded, fromRules.BannerID) - }) -} - -func TestApplyCommandToAnOrganization(t *testing.T) { - config.UseTestFile(t) - needCouchDB(t) - useCommandContexts(t) - - orgCommand := func(t *testing.T, orgID string, revision int64) Command { - t.Helper() - cmd := fixture(t, "organization") - cmd.Tenant = orgID - cmd.Revision = revision - return cmd - } - - t.Run("every instance of the organization gets the banner", func(t *testing.T) { - orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) - first := newInstance(t, commandContext, "en", orgID) - second := newInstance(t, commandContext, "fr", orgID) - - other := newInstance(t, commandContext, "en", orgID+"-other") - other.OrgDomain = orgID - require.NoError(t, couchdb.UpdateDoc(prefixer.GlobalPrefixer, other)) - - require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) - - for _, inst := range []*instance.Instance{first, second} { - stored := storedBanner(t, inst) - require.NotNil(t, stored, inst.Domain) - assert.Equal(t, "billing.restricted", stored.BannerID) - } - assert.Equal(t, "fr", storedBanner(t, second).Lang, "each member reads its own language") - assert.Nil(t, storedBanner(t, other), "a matching organization domain must not select another tenant") - - clear := orgCommand(t, orgID, 8) - clear = Command{Category: clear.Category, Tenant: clear.Tenant, Revision: clear.Revision, Timestamp: clear.Timestamp, Clear: true} - require.NoError(t, ApplyCommand(clear)) - assert.Nil(t, storedBanner(t, first)) - assert.Nil(t, storedBanner(t, second)) - }) - - t.Run("a replay reaches a member provisioned after the command", func(t *testing.T) { - orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) - first := newInstance(t, commandContext, "en", orgID) - require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) - before := storedBanner(t, first) - require.NotNil(t, before) - - joined := newInstance(t, commandContext, "en", orgID) - require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) - - require.NotNil(t, storedBanner(t, joined), "an equal revision resolves membership again") - assert.Equal(t, before.DocRev, storedBanner(t, first).DocRev, "and leaves the members it already reached alone") - }) - - t.Run("a disallowed category skips only that instance", func(t *testing.T) { - orgID := fmt.Sprintf("acme-org-%d", time.Now().UnixNano()) - accepting := newInstance(t, commandContext, "en", orgID) - refusing := newInstance(t, refusedContext, "en", orgID) - - require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) - before := storedBanner(t, accepting) - require.NotNil(t, before) - assert.Nil(t, storedBanner(t, refusing)) - stored, err := Stored(refusing, CategoryBilling) - require.NoError(t, err) - assert.Nil(t, stored, "skipping must not advance the member's revision") - - conf := config.GetConfig() - conf.Contexts[refusedContext] = map[string]interface{}{ - "enable_banners": true, - "banner_command_categories": []interface{}{CategoryBilling}, - } - t.Cleanup(func() { - conf.Contexts[refusedContext] = map[string]interface{}{"enable_banners": true} - }) - - // Replay reaches a previously skipped member after its context allows - // the category, without changing members that already accepted it. - require.NoError(t, ApplyCommand(orgCommand(t, orgID, 7))) - assert.Equal(t, before.DocRev, storedBanner(t, accepting).DocRev) - assert.NotNil(t, storedBanner(t, refusing)) - }) - - t.Run("an organization with no instance is a no-op", func(t *testing.T) { - assert.NoError(t, ApplyCommand(orgCommand(t, fmt.Sprintf("empty-org-%d", time.Now().UnixNano()), 7))) - }) -} - -func TestCommandStateSharesTheBannerDocument(t *testing.T) { - config.UseTestFile(t) - needCouchDB(t) - useCommandContexts(t) - - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(materialize(t, inst, 42))) - - stored, err := Stored(inst, CategoryBilling) - require.NoError(t, err) - require.NotNil(t, stored) - assert.Equal(t, consts.Banners, stored.DocType()) - assert.Equal(t, int64(42), stored.Revision) - assert.False(t, stored.Cleared) - assert.Equal(t, "banner-command-42", stored.EventID) -} - -func TestClearBeforeMaterialize(t *testing.T) { - config.UseTestFile(t) - needCouchDB(t) - useCommandContexts(t) - inst := newInstance(t, commandContext, "en", "") - require.NoError(t, ApplyCommand(clearCommand(t, inst, 2))) - cleared := storedState(t, inst) - require.True(t, cleared.Cleared) - require.NotNil(t, cleared.EndsAt) - assert.True(t, cleared.EndsAt.Before(time.Now())) - require.NoError(t, ApplyCommand(materialize(t, inst, 1))) - assert.Equal(t, cleared.DocRev, storedState(t, inst).DocRev) - require.NoError(t, ApplyCommand(materialize(t, inst, 3))) - require.NotNil(t, storedBanner(t, inst)) - assert.False(t, storedState(t, inst).Cleared) -} From a0811b11a2ba84e564d8c44881755e5cd716f5b4 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 12:33:17 +0100 Subject: [PATCH 12/17] fix: update banner fanout to continue after instance errors --- docs/banners.md | 4 +++- model/banner/command.go | 5 +++-- model/banner/command_io_test.go | 28 ++++++++++++++++++++-------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index ea55d53c6bb..f4d62c39f35 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -24,7 +24,9 @@ counters need separate categories. `quota` is reserved for the stack's rules. Instances that disable banners or disallow the category are skipped; other eligible recipients still receive the command. Skipping an instance leaves its -existing documents and recorded revision unchanged. +existing documents and recorded revision unchanged. An error on one instance +does not stop processing the others. The stack returns all failures after +attempting every recipient, so delivery can be retried. ### Commands diff --git a/model/banner/command.go b/model/banner/command.go index a1aa279c8e0..07c91213cff 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -101,12 +101,13 @@ func ApplyCommand(cmd Command) error { if err != nil { return err } + var errs []error for _, inst := range instances { if err := cmd.applyTo(inst); err != nil { - return fmt.Errorf("%s: %w", inst.Domain, err) + errs = append(errs, fmt.Errorf("%s: %w", inst.Domain, err)) } } - return nil + return errors.Join(errs...) } // targets resolves what the backend addressed. An organization with no diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go index 239d8994e24..0c29f20eb16 100644 --- a/model/banner/command_io_test.go +++ b/model/banner/command_io_test.go @@ -22,7 +22,7 @@ type commandRoundTripper func(*http.Request) (*http.Response, error) func (f commandRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } -func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { +func TestCommandFanoutContinuesAfterStorageFailures(t *testing.T) { config.UseTestFile(t) testutils.NeedCouchdb(t) useCommandContexts(t) @@ -34,18 +34,22 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { require.NoError(t, couchdb.UpdateDoc(prefixer.GlobalPrefixer, first)) newInstance(t, commandContext, "fr", org) newInstance(t, commandContext, "en", org) + newInstance(t, commandContext, "en", org) members, err := lifecycle.ListOrgInstancesByID(org) require.NoError(t, err) - require.Len(t, members, 3) + require.Len(t, members, 4) cmd := fixture(t, "organization") cmd.Tenant = org - failPath := "/" + couchdb.EscapeCouchdbName(members[1].DBPrefix()+"/"+consts.Banners) + "/banner-billing" + failures := map[string]*atomic.Bool{} + for _, inst := range members[1:3] { + path := "/" + couchdb.EscapeCouchdbName(inst.DBPrefix()+"/"+consts.Banners) + "/banner-billing" + failures[path] = &atomic.Bool{} + } client := config.CouchClient() original := client.Transport t.Cleanup(func() { client.Transport = original }) - var failed atomic.Bool client.Transport = commandRoundTripper(func(r *http.Request) (*http.Response, error) { - if r.Method == http.MethodGet && r.URL.Path == failPath && failed.CompareAndSwap(false, true) { + if failed := failures[r.URL.Path]; r.Method == http.MethodGet && failed != nil && failed.CompareAndSwap(false, true) { return nil, errors.New("simulated projection outage") } return original.RoundTrip(r) @@ -53,18 +57,26 @@ func TestCommandPartialFanoutRetriesStorageFailure(t *testing.T) { err = banner.ApplyCommand(cmd) require.ErrorContains(t, err, "simulated projection outage") assert.NotErrorIs(t, err, banner.ErrInvalidCommand) + for _, inst := range members[1:3] { + assert.ErrorContains(t, err, inst.Domain, "every failed member must be reported") + } before := storedBanner(t, members[0]) require.NotNil(t, before) assert.Nil(t, storedBanner(t, members[1])) assert.Nil(t, storedBanner(t, members[2])) - retained, err := banner.Stored(members[1], banner.CategoryBilling) - require.NoError(t, err) - require.Nil(t, retained, "nothing is recorded for a member whose banner was not written") + last := storedBanner(t, members[3]) + require.NotNil(t, last, "failures must not block later members") + for _, inst := range members[1:3] { + retained, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + require.Nil(t, retained, "a failed member must not advance its revision") + } require.NoError(t, banner.ApplyCommand(cmd)) for _, inst := range members { require.NotNil(t, storedBanner(t, inst)) } assert.Equal(t, before.DocRev, storedBanner(t, members[0]).DocRev) + assert.Equal(t, last.DocRev, storedBanner(t, members[3]).DocRev) } func TestCommandClearRetriesProjectionFailure(t *testing.T) { From a27af446e3838979de05d200b33b0ca92ca6feb7 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 12:55:36 +0100 Subject: [PATCH 13/17] fix(banners): warn on skipped instances and allowlist CTA hosts An instance skipped for a disallowed category or CTA host now logs a warning with the reason. CTA URLs must use a host listed in the context's banner_cta_hosts, checked on apply and on refresh. The docs state that the revision is kept per instance and category, shared by tenant and workplace commands. --- cozy.example.yaml | 3 ++ docs/banners.md | 21 ++++++++----- model/banner/command.go | 40 +++++++++++++++++++++--- model/banner/command_integration_test.go | 13 ++++++++ model/banner/trigger.go | 6 ++-- model/instance/instance.go | 16 ++++++++-- pkg/rabbitmq/banner_commands_test.go | 1 + pkg/rabbitmq/handlers_test.go | 1 + 8 files changed, 82 insertions(+), 19 deletions(-) diff --git a/cozy.example.yaml b/cozy.example.yaml index 5bb28774d63..a1321896c66 100644 --- a/cozy.example.yaml +++ b/cozy.example.yaml @@ -548,6 +548,9 @@ contexts: banner_command_categories: - billing - trial + # The hosts a banner command's call to action may link to. + banner_cta_hosts: + - manager.example.org # List of applications that can be automatically updated even if the # permissions have changed additional_platform_apps: diff --git a/docs/banners.md b/docs/banners.md index f4d62c39f35..7d125920b0a 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -16,15 +16,20 @@ contexts: banner_command_categories: - billing - trial + banner_cta_hosts: + - manager.example.org ``` Broker credentials, permissions and bindings control who can publish. Each -category must have one owner; overlapping audiences with independent revision -counters need separate categories. `quota` is reserved for the stack's rules. - -Instances that disable banners or disallow the category are skipped; other -eligible recipients still receive the command. Skipping an instance leaves its -existing documents and recorded revision unchanged. An error on one instance +category must have one owner and one addressing mode: the stack keeps one +revision per instance and category, shared by `tenant` and `workplaceFqdn` +commands, so never address a category both ways. `quota` is reserved for the +stack's rules. + +Instances that disable banners are skipped. Instances whose context does not +list the category or a CTA host are skipped with a warning log. Other eligible +recipients still receive the command. Skipping an instance leaves its existing +documents and recorded revision unchanged. An error on one instance does not stop processing the others. The stack returns all failures after attempting every recipient, so delivery can be retried. @@ -77,7 +82,7 @@ and [shared fixtures](../model/banner/testdata) for complete examples. | --- | --- | --- | | `category` | always | Matches `^[a-z][a-z0-9-]{0,31}$`; `quota` is rejected. | | `workplaceFqdn` / `tenant` | exactly one | A single instance host name / a B2B organization ID matching instance `org_id`, whose members receive the command; `tenant` is at most 256 bytes with no surrounding whitespace. | -| `revision` | always | Positive counter, increasing per target and category. | +| `revision` | always | Positive counter, increasing per category. | | `timestamp` | always | Decision time in positive epoch seconds, within the RFC3339 range. Does not order commands. | | `eventId` | no | Correlation ID, at most 256 bytes. | | `bannerId` | materialize | Matches `^[a-z0-9.-]{1,64}$`. Keep it for the same occurrence to preserve dismissal; change it for a new occurrence. | @@ -85,7 +90,7 @@ and [shared fixtures](../model/banner/testdata) for complete examples. | `surface` | materialize | `banner` or `modal`. | | `text` | materialize | Locale map with nonempty `en`; at most 1024 bytes per locale. | | `title` | no | Locale map with nonempty `en` when supplied; at most 256 bytes per locale. | -| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes). A secondary CTA requires a primary one. | +| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes) whose host is in `banner_cta_hosts`. A secondary CTA requires a primary one. | | `dismissible` | no | Defaults to false. A modal without a CTA is made dismissible. | | `priority` | no | 0–1000; defaults to 0. Quota banners use 50 and 100. | | `startsAt`, `endsAt` | no | RFC3339. If both are supplied, `startsAt` must precede `endsAt`. An explicit start replaces the stored start; omission preserves it for the same occurrence when compatible with the end, otherwise defaults to the command's decision time. | diff --git a/model/banner/command.go b/model/banner/command.go index 07c91213cff..d9b63f92f6a 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "regexp" "strings" "time" @@ -45,9 +46,10 @@ type Command struct { // EventID is the backend's correlation id, logged for traceability. EventID string `json:"eventId,omitempty"` - // Revision is a positive counter the backend increments per target and - // category. Delivery is at-least-once and unordered, so this (not the - // arrival time) orders a command against what is stored. + // Revision is a positive counter the backend increments per category. The + // stack stores one per instance and category, shared by tenant and + // workplace commands. Delivery is at-least-once and unordered, so this + // (not the arrival time) orders a command against what is stored. Revision int64 `json:"revision"` // Timestamp is when the backend decided, in epoch seconds. Provenance // only: it orders nothing. @@ -129,8 +131,11 @@ func (cmd Command) targets() ([]*instance.Instance, error) { } func (cmd Command) applyTo(inst *instance.Instance) error { - // Instances that disable banners or disallow this category are skipped. - if !inst.HasBannersEnabled() || !inst.AllowsBannerCategory(cmd.Category) { + if !inst.HasBannersEnabled() { + return nil + } + if reason := cmd.refusal(inst); reason != "" { + log(inst).Warnf("%s: skipping revision %d, %s", cmd.Category, cmd.Revision, reason) return nil } @@ -155,6 +160,31 @@ func (cmd Command) applyTo(inst *instance.Instance) error { return Materialize(inst, cmd.Category, cmd.banner(inst.Locale), time.Now()) } +// refusal says why the instance's context does not accept the command, or "" +// when it does. +func (cmd Command) refusal(inst *instance.Instance) string { + if !inst.AllowsBannerCategory(cmd.Category) { + return "category not in banner_command_categories" + } + for _, cta := range []*CommandCTA{cmd.CTA, cmd.SecondaryCTA} { + if cta == nil { + continue + } + if host := ctaHost(cta.URL); !inst.AllowsBannerCTAHost(host) { + return fmt.Sprintf("CTA host %q not in banner_cta_hosts", host) + } + } + return "" +} + +func ctaHost(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + return strings.ToLower(u.Hostname()) +} + // banner keeps the accepted command with the localized presentation. Clears // retain the revision in an expired document, including when no banner existed. func (cmd Command) banner(instanceLocale string) *Banner { diff --git a/model/banner/command_integration_test.go b/model/banner/command_integration_test.go index 7d2885556dc..4c3896e151f 100644 --- a/model/banner/command_integration_test.go +++ b/model/banner/command_integration_test.go @@ -60,6 +60,7 @@ func useCommandContexts(t *testing.T) { commandContext: map[string]interface{}{ "enable_banners": true, "banner_command_categories": []interface{}{banner.CategoryBilling, banner.CategoryTrial}, + "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, }, refusedContext: map[string]interface{}{ "enable_banners": true, @@ -409,6 +410,17 @@ func TestApplyCommand(t *testing.T) { assert.Nil(t, stored, "skipping must not advance the revision") }) + t.Run("a CTA host the context does not accept is skipped", func(t *testing.T) { + inst := newInstance(t, commandContext, "en", "") + cmd := materialize(t, inst, 85) + cmd.CTA.URL = "https://evil.example/pay" + + require.NoError(t, banner.ApplyCommand(cmd)) + stored, err := banner.Stored(inst, banner.CategoryBilling) + require.NoError(t, err) + assert.Nil(t, stored, "skipping must not advance the revision") + }) + t.Run("an instance that displays no banner is a no-op", func(t *testing.T) { inst := newInstance(t, noBannerContext, "en", "") @@ -544,6 +556,7 @@ func TestApplyCommandToAnOrganization(t *testing.T) { conf.Contexts[refusedContext] = map[string]interface{}{ "enable_banners": true, "banner_command_categories": []interface{}{banner.CategoryBilling}, + "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, } t.Cleanup(func() { conf.Contexts[refusedContext] = map[string]interface{}{"enable_banners": true} diff --git a/model/banner/trigger.go b/model/banner/trigger.go index 1742be977f5..63f2a390990 100644 --- a/model/banner/trigger.go +++ b/model/banner/trigger.go @@ -140,9 +140,9 @@ func refreshCommandsAt(domain string) error { if state.Cleared || state.Accepted == nil { continue } - // A category the context has stopped accepting is left alone rather - // than rewritten; turning the setting off needs a cleanup either way. - if !inst.AllowsBannerCategory(state.Category) { + // A command the context has stopped accepting is left alone rather + // than rewritten; turning a setting off needs a cleanup either way. + if state.Accepted.refusal(inst) != "" { continue } if err := Materialize(inst, state.Category, state.Accepted.banner(inst.Locale), now); err != nil { diff --git a/model/instance/instance.go b/model/instance/instance.go index 7edc3383b4f..056ef415286 100644 --- a/model/instance/instance.go +++ b/model/instance/instance.go @@ -904,16 +904,26 @@ func (i *Instance) HasBannersEnabled() bool { // AllowsBannerCategory reports whether the context settings allow the banner // command queue to write a category on this instance. func (i *Instance) AllowsBannerCategory(category string) bool { + return i.contextListHas("banner_command_categories", category) +} + +// AllowsBannerCTAHost reports whether the context settings allow a banner +// command's call to action to link to this host. +func (i *Instance) AllowsBannerCTAHost(host string) bool { + return i.contextListHas("banner_cta_hosts", host) +} + +func (i *Instance) contextListHas(key, value string) bool { ctxSettings, ok := i.SettingsContext() if !ok { return false } - categories, ok := ctxSettings["banner_command_categories"].([]interface{}) + list, ok := ctxSettings[key].([]interface{}) if !ok { return false } - for _, allowed := range categories { - if name, ok := allowed.(string); ok && name == category { + for _, item := range list { + if s, ok := item.(string); ok && s == value { return true } } diff --git a/pkg/rabbitmq/banner_commands_test.go b/pkg/rabbitmq/banner_commands_test.go index 9433fd289c2..7290395d377 100644 --- a/pkg/rabbitmq/banner_commands_test.go +++ b/pkg/rabbitmq/banner_commands_test.go @@ -43,6 +43,7 @@ func TestBannerCommandsThroughTheBroker(t *testing.T) { contextName: map[string]interface{}{ "enable_banners": true, "banner_command_categories": []interface{}{"billing"}, + "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, }, } t.Cleanup(func() { conf.Contexts = previous }) diff --git a/pkg/rabbitmq/handlers_test.go b/pkg/rabbitmq/handlers_test.go index 0c053236c37..c81482a9554 100644 --- a/pkg/rabbitmq/handlers_test.go +++ b/pkg/rabbitmq/handlers_test.go @@ -111,6 +111,7 @@ func TestBannerCommandHandler(t *testing.T) { contextName: map[string]interface{}{ "enable_banners": true, "banner_command_categories": []interface{}{"billing"}, + "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, }, } t.Cleanup(func() { conf.Contexts = previous }) From f5cdc7ff5ca2bcda8cd5bb3f19b1af84d5264afd Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 13:13:10 +0100 Subject: [PATCH 14/17] fix: update banner test whitespace for lint --- pkg/rabbitmq/banner_commands_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/rabbitmq/banner_commands_test.go b/pkg/rabbitmq/banner_commands_test.go index 7290395d377..cb02abdd30d 100644 --- a/pkg/rabbitmq/banner_commands_test.go +++ b/pkg/rabbitmq/banner_commands_test.go @@ -178,7 +178,6 @@ func TestBannerCommandsThroughTheBroker(t *testing.T) { trial, err := banner.Stored(inst, banner.CategoryTrial) require.NoError(t, err) assert.Nil(t, trial) - }) } From c31b2b31abae1753ae22a88893d165daefb4fcaf Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 13:18:29 +0100 Subject: [PATCH 15/17] fix: update MinIO test image registry --- tests/testutils/minio_utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testutils/minio_utils.go b/tests/testutils/minio_utils.go index 8af8bf262ab..c2395ee07e6 100644 --- a/tests/testutils/minio_utils.go +++ b/tests/testutils/minio_utils.go @@ -35,7 +35,7 @@ func StartMinio(t *testing.T) *MinioFixture { hostPort := getFreePort(t) req := tc.ContainerRequest{ - Image: "minio/minio:RELEASE.2025-02-28T09-55-16Z", + Image: "quay.io/minio/minio:RELEASE.2025-02-28T09-55-16Z", ExposedPorts: []string{"9000/tcp"}, Env: map[string]string{ "MINIO_ROOT_USER": accessKey, From 2b202526a11b9bbe8a7bb1e02642ce08c429cc64 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 17:00:32 +0100 Subject: [PATCH 16/17] refactor(banners): rename the organization target to orgId --- docs/banners.md | 4 ++-- model/banner/command.go | 22 +++++++++++----------- model/banner/command_integration_test.go | 4 ++-- model/banner/command_io_test.go | 2 +- model/banner/command_test.go | 18 +++++++++--------- model/banner/testdata/organization.json | 2 +- pkg/rabbitmq/handlers.go | 2 +- 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/banners.md b/docs/banners.md index 7d125920b0a..76691d9d35c 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -22,7 +22,7 @@ contexts: Broker credentials, permissions and bindings control who can publish. Each category must have one owner and one addressing mode: the stack keeps one -revision per instance and category, shared by `tenant` and `workplaceFqdn` +revision per instance and category, shared by `orgId` and `workplaceFqdn` commands, so never address a category both ways. `quota` is reserved for the stack's rules. @@ -81,7 +81,7 @@ and [shared fixtures](../model/banner/testdata) for complete examples. | Field | Required | Contract | | --- | --- | --- | | `category` | always | Matches `^[a-z][a-z0-9-]{0,31}$`; `quota` is rejected. | -| `workplaceFqdn` / `tenant` | exactly one | A single instance host name / a B2B organization ID matching instance `org_id`, whose members receive the command; `tenant` is at most 256 bytes with no surrounding whitespace. | +| `workplaceFqdn` / `orgId` | exactly one | A single instance host name / a B2B organization ID matching instance `org_id`, whose members receive the command; `orgId` is at most 256 bytes with no surrounding whitespace. | | `revision` | always | Positive counter, increasing per category. | | `timestamp` | always | Decision time in positive epoch seconds, within the RFC3339 range. Does not order commands. | | `eventId` | no | Correlation ID, at most 256 bytes. | diff --git a/model/banner/command.go b/model/banner/command.go index d9b63f92f6a..cf2b5458bd2 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -39,15 +39,15 @@ type CommandCTA struct { type Command struct { Category string `json:"category"` - // Exactly one of Tenant and WorkplaceFqdn is set. Tenant is the B2B + // Exactly one of OrgID and WorkplaceFqdn is set. OrgID is the B2B // organization ID, and every instance under it gets the banner. - Tenant string `json:"tenant,omitempty"` + OrgID string `json:"orgId,omitempty"` WorkplaceFqdn string `json:"workplaceFqdn,omitempty"` // EventID is the backend's correlation id, logged for traceability. EventID string `json:"eventId,omitempty"` // Revision is a positive counter the backend increments per category. The - // stack stores one per instance and category, shared by tenant and + // stack stores one per instance and category, shared by organization and // workplace commands. Delivery is at-least-once and unordered, so this // (not the arrival time) orders a command against what is stored. Revision int64 `json:"revision"` @@ -86,7 +86,7 @@ const ( maxURLLen = 2048 maxPriority = 1000 maxEventIDLen = 256 - maxTenantLen = 256 + maxOrgIDLen = 256 maxLocaleLen = 35 // MaxCommandBytes bounds the JSON body at the transport boundary. MaxCommandBytes = 256 * 1024 @@ -116,10 +116,10 @@ func ApplyCommand(cmd Command) error { // instance is a no-op. A missing workplace is retryable (not invalid): the // stack cannot tell a deleted instance from one still being provisioned. func (cmd Command) targets() ([]*instance.Instance, error) { - if cmd.Tenant != "" { - list, err := lifecycle.ListOrgInstancesByID(cmd.Tenant) + if cmd.OrgID != "" { + list, err := lifecycle.ListOrgInstancesByID(cmd.OrgID) if err != nil { - return nil, fmt.Errorf("cannot list the instances of organization %s: %w", cmd.Tenant, err) + return nil, fmt.Errorf("cannot list the instances of organization %s: %w", cmd.OrgID, err) } return list, nil } @@ -263,11 +263,11 @@ func (cmd Command) validate() error { return fmt.Errorf("%w: the %s category is reserved for the stack's own rules", ErrInvalidCommand, CategoryQuota) } - if (cmd.Tenant == "") == (cmd.WorkplaceFqdn == "") { - return fmt.Errorf("%w: exactly one of tenant and workplaceFqdn is required", ErrInvalidCommand) + if (cmd.OrgID == "") == (cmd.WorkplaceFqdn == "") { + return fmt.Errorf("%w: exactly one of orgId and workplaceFqdn is required", ErrInvalidCommand) } - if cmd.Tenant != "" && (len(cmd.Tenant) > maxTenantLen || strings.TrimSpace(cmd.Tenant) != cmd.Tenant) { - return fmt.Errorf("%w: tenant must be at most %d bytes with no surrounding whitespace", ErrInvalidCommand, maxTenantLen) + if cmd.OrgID != "" && (len(cmd.OrgID) > maxOrgIDLen || strings.TrimSpace(cmd.OrgID) != cmd.OrgID) { + return fmt.Errorf("%w: orgId must be at most %d bytes with no surrounding whitespace", ErrInvalidCommand, maxOrgIDLen) } if cmd.WorkplaceFqdn != "" && !targetFormat.MatchString(cmd.WorkplaceFqdn) { return fmt.Errorf("%w: %q is not a valid target", ErrInvalidCommand, cmd.WorkplaceFqdn) diff --git a/model/banner/command_integration_test.go b/model/banner/command_integration_test.go index 4c3896e151f..658c0c3c191 100644 --- a/model/banner/command_integration_test.go +++ b/model/banner/command_integration_test.go @@ -494,7 +494,7 @@ func TestApplyCommandToAnOrganization(t *testing.T) { orgCommand := func(t *testing.T, orgID string, revision int64) banner.Command { t.Helper() cmd := fixture(t, "organization") - cmd.Tenant = orgID + cmd.OrgID = orgID cmd.Revision = revision return cmd } @@ -519,7 +519,7 @@ func TestApplyCommandToAnOrganization(t *testing.T) { assert.Nil(t, storedBanner(t, other), "a matching organization domain must not select another tenant") clear := orgCommand(t, orgID, 8) - clear = banner.Command{Category: clear.Category, Tenant: clear.Tenant, Revision: clear.Revision, Timestamp: clear.Timestamp, Clear: true} + clear = banner.Command{Category: clear.Category, OrgID: clear.OrgID, Revision: clear.Revision, Timestamp: clear.Timestamp, Clear: true} require.NoError(t, banner.ApplyCommand(clear)) assert.Nil(t, storedBanner(t, first)) assert.Nil(t, storedBanner(t, second)) diff --git a/model/banner/command_io_test.go b/model/banner/command_io_test.go index 0c29f20eb16..a14d12620a9 100644 --- a/model/banner/command_io_test.go +++ b/model/banner/command_io_test.go @@ -39,7 +39,7 @@ func TestCommandFanoutContinuesAfterStorageFailures(t *testing.T) { require.NoError(t, err) require.Len(t, members, 4) cmd := fixture(t, "organization") - cmd.Tenant = org + cmd.OrgID = org failures := map[string]*atomic.Bool{} for _, inst := range members[1:3] { path := "/" + couchdb.EscapeCouchdbName(inst.DBPrefix()+"/"+consts.Banners) + "/banner-billing" diff --git a/model/banner/command_test.go b/model/banner/command_test.go index 232f2448fe1..06346022cee 100644 --- a/model/banner/command_test.go +++ b/model/banner/command_test.go @@ -39,7 +39,7 @@ func TestFixturesAreTheContract(t *testing.T) { cmd := fixture(t, "materialize") assert.Equal(t, "alice.twake.app", cmd.WorkplaceFqdn) - assert.Empty(t, cmd.Tenant) + assert.Empty(t, cmd.OrgID) assert.Equal(t, "banner-command-42", cmd.EventID) assert.Equal(t, int64(42), cmd.Revision) assert.Equal(t, int64(decidedAt), cmd.Timestamp) @@ -68,10 +68,10 @@ func TestFixturesAreTheContract(t *testing.T) { assert.NoError(t, cmd.validate()) }) - t.Run("an organization is addressed by its tenant ID", func(t *testing.T) { + t.Run("an organization is addressed by its org ID", func(t *testing.T) { cmd := fixture(t, "organization") - assert.Equal(t, "acme_org:123", cmd.Tenant) + assert.Equal(t, "acme_org:123", cmd.OrgID) assert.Empty(t, cmd.WorkplaceFqdn) assert.Equal(t, SurfaceModal, cmd.Surface) assert.NoError(t, cmd.validate()) @@ -113,11 +113,11 @@ func TestValidateRejections(t *testing.T) { {"category starting with a digit", func(c *Command) { c.Category = "2fa" }, "not a valid category"}, {"category too long", func(c *Command) { c.Category = strings.Repeat("a", 33) }, "not a valid category"}, {"the quota category", func(c *Command) { c.Category = CategoryQuota }, "reserved for the stack's own rules"}, - {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of tenant and workplaceFqdn"}, - {"both targets", func(c *Command) { c.Tenant = "acme.example" }, "exactly one of tenant and workplaceFqdn"}, - {"tenant too long", func(c *Command) { c.WorkplaceFqdn = ""; c.Tenant = strings.Repeat("a", maxTenantLen+1) }, "tenant must be at most"}, - {"blank tenant", func(c *Command) { c.WorkplaceFqdn = ""; c.Tenant = " " }, "no surrounding whitespace"}, - {"tenant with surrounding whitespace", func(c *Command) { c.WorkplaceFqdn = ""; c.Tenant = " acme" }, "no surrounding whitespace"}, + {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of orgId and workplaceFqdn"}, + {"both targets", func(c *Command) { c.OrgID = "acme.example" }, "exactly one of orgId and workplaceFqdn"}, + {"orgId too long", func(c *Command) { c.WorkplaceFqdn = ""; c.OrgID = strings.Repeat("a", maxOrgIDLen+1) }, "orgId must be at most"}, + {"blank orgId", func(c *Command) { c.WorkplaceFqdn = ""; c.OrgID = " " }, "no surrounding whitespace"}, + {"orgId with surrounding whitespace", func(c *Command) { c.WorkplaceFqdn = ""; c.OrgID = " acme" }, "no surrounding whitespace"}, {"a target with a path", func(c *Command) { c.WorkplaceFqdn = "alice.twake.app/../bob" }, "is not a valid target"}, {"a target with a scheme", func(c *Command) { c.WorkplaceFqdn = "https://alice.twake.app" }, "is not a valid target"}, {"a target too long", func(c *Command) { c.WorkplaceFqdn = strings.Repeat("a", 256) }, "is not a valid target"}, @@ -208,7 +208,7 @@ func TestValidateRejections(t *testing.T) { {"oversized event id", func(c *Command) { c.EventID = strings.Repeat("e", maxEventIDLen+1) }, "eventId is longer than"}, {"oversized wording", func(c *Command) { c.Text = Localized{"en": strings.Repeat("x", 2<<20)} }, "clear must not carry presentation"}, {"ordinary wording", func(c *Command) { c.Text = Localized{"en": "ignored?"} }, "clear must not carry presentation"}, - {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of tenant and workplaceFqdn"}, + {"no target", func(c *Command) { c.WorkplaceFqdn = "" }, "exactly one of orgId and workplaceFqdn"}, {"the quota category", func(c *Command) { c.Category = CategoryQuota }, "reserved"}, } { cmd := clear() diff --git a/model/banner/testdata/organization.json b/model/banner/testdata/organization.json index a429b5678c2..8611bd378f6 100644 --- a/model/banner/testdata/organization.json +++ b/model/banner/testdata/organization.json @@ -1,5 +1,5 @@ { - "tenant": "acme_org:123", + "orgId": "acme_org:123", "eventId": "banner-command-7", "revision": 7, "timestamp": 1788944400, diff --git a/pkg/rabbitmq/handlers.go b/pkg/rabbitmq/handlers.go index 7943b583078..fc8dbbf3ad0 100644 --- a/pkg/rabbitmq/handlers.go +++ b/pkg/rabbitmq/handlers.go @@ -875,6 +875,6 @@ func (h *BannerCommandHandler) Handle(ctx context.Context, d amqp.Delivery) erro // Accepted, not applied: a command that lost to a newer revision, or // addressed an instance that displays no banner, gets here too. log.Infof("banner.commands: %s accepted for %s (category %s, revision %d, event %s)", - d.RoutingKey, cmd.Tenant+cmd.WorkplaceFqdn, cmd.Category, cmd.Revision, cmd.EventID) + d.RoutingKey, cmd.OrgID+cmd.WorkplaceFqdn, cmd.Category, cmd.Revision, cmd.EventID) return nil } From 3d0c7d67877391aa313d3c57818a54a5d6fd3f47 Mon Sep 17 00:00:00 2001 From: MontaGhanmy Date: Mon, 14 Sep 2026 17:00:32 +0100 Subject: [PATCH 17/17] refactor(banners): group banner settings and allow a category wildcard --- cozy.example.yaml | 33 ++++++------ docs/banners.md | 18 ++++--- model/banner/command.go | 11 ++-- model/banner/command_integration_test.go | 20 +++++--- model/banner/trigger.go | 4 +- model/instance/instance.go | 65 +++++++++++------------- model/instance/instance_test.go | 38 ++++++++++++++ pkg/rabbitmq/banner_commands_test.go | 8 +-- pkg/rabbitmq/handlers_test.go | 8 +-- 9 files changed, 128 insertions(+), 77 deletions(-) diff --git a/cozy.example.yaml b/cozy.example.yaml index a1321896c66..84caf0a7cb5 100644 --- a/cozy.example.yaml +++ b/cozy.example.yaml @@ -535,22 +535,25 @@ contexts: # Feature flags features: - hide_konnector_errors - # Materialize the platform banners (io.cozy.banners) for the instances of - # this context. Off by default, so the rules can ship before the clients - # that render them. Turning it back off stops the writes and leaves the - # documents already materialized in place. - enable_banners: true - # The banner categories the stack.banner.commands queue is allowed to - # write. Its publisher is authenticated by its broker credentials and - # bindings, so this says what it may say, not who it is. The quota - # category is always refused: the stack measures disk usage itself. + # Platform banners (io.cozy.banners) for the instances of this context. # See docs/banners.md. - banner_command_categories: - - billing - - trial - # The hosts a banner command's call to action may link to. - banner_cta_hosts: - - manager.example.org + banner: + # Off by default, so the rules can ship before the clients that render + # them. Turning it back off stops the writes and leaves the documents + # already materialized in place. + enabled: true + # The categories the stack.banner.commands queue is allowed to write, or + # "*" for all of them. Its publisher is authenticated by its broker + # credentials and bindings, so this says what it may say, not who it + # is. The quota category is always refused: the stack measures disk + # usage itself. + command_categories: + - billing + - trial + # The hosts a banner command's call to action may link to. There is no + # wildcard. + cta_hosts: + - manager.example.org # List of applications that can be automatically updated even if the # permissions have changed additional_platform_apps: diff --git a/docs/banners.md b/docs/banners.md index 76691d9d35c..52d93e20996 100644 --- a/docs/banners.md +++ b/docs/banners.md @@ -12,14 +12,18 @@ Enable banners and allow the publisher's categories in each recipient context: ```yaml contexts: b2b_twake_default: - enable_banners: true - banner_command_categories: - - billing - - trial - banner_cta_hosts: - - manager.example.org + banner: + enabled: true + command_categories: + - billing + - trial + cta_hosts: + - manager.example.org ``` +Set `command_categories` to `["*"]` to allow every category except `quota`. +`cta_hosts` has no wildcard. + Broker credentials, permissions and bindings control who can publish. Each category must have one owner and one addressing mode: the stack keeps one revision per instance and category, shared by `orgId` and `workplaceFqdn` @@ -90,7 +94,7 @@ and [shared fixtures](../model/banner/testdata) for complete examples. | `surface` | materialize | `banner` or `modal`. | | `text` | materialize | Locale map with nonempty `en`; at most 1024 bytes per locale. | | `title` | no | Locale map with nonempty `en` when supplied; at most 256 bytes per locale. | -| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes) whose host is in `banner_cta_hosts`. A secondary CTA requires a primary one. | +| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes) whose host is in `banner.cta_hosts`. A secondary CTA requires a primary one. | | `dismissible` | no | Defaults to false. A modal without a CTA is made dismissible. | | `priority` | no | 0–1000; defaults to 0. Quota banners use 50 and 100. | | `startsAt`, `endsAt` | no | RFC3339. If both are supplied, `startsAt` must precede `endsAt`. An explicit start replaces the stored start; omission preserves it for the same occurrence when compatible with the end, otherwise defaults to the command's decision time. | diff --git a/model/banner/command.go b/model/banner/command.go index cf2b5458bd2..e80553dbb7d 100644 --- a/model/banner/command.go +++ b/model/banner/command.go @@ -131,7 +131,7 @@ func (cmd Command) targets() ([]*instance.Instance, error) { } func (cmd Command) applyTo(inst *instance.Instance) error { - if !inst.HasBannersEnabled() { + if !inst.BannerSettings().Enabled { return nil } if reason := cmd.refusal(inst); reason != "" { @@ -163,15 +163,16 @@ func (cmd Command) applyTo(inst *instance.Instance) error { // refusal says why the instance's context does not accept the command, or "" // when it does. func (cmd Command) refusal(inst *instance.Instance) string { - if !inst.AllowsBannerCategory(cmd.Category) { - return "category not in banner_command_categories" + settings := inst.BannerSettings() + if !settings.AllowsCategory(cmd.Category) { + return "category not in banner.command_categories" } for _, cta := range []*CommandCTA{cmd.CTA, cmd.SecondaryCTA} { if cta == nil { continue } - if host := ctaHost(cta.URL); !inst.AllowsBannerCTAHost(host) { - return fmt.Sprintf("CTA host %q not in banner_cta_hosts", host) + if host := ctaHost(cta.URL); !settings.AllowsCTAHost(host) { + return fmt.Sprintf("CTA host %q not in banner.cta_hosts", host) } } return "" diff --git a/model/banner/command_integration_test.go b/model/banner/command_integration_test.go index 658c0c3c191..014fd8617d0 100644 --- a/model/banner/command_integration_test.go +++ b/model/banner/command_integration_test.go @@ -58,12 +58,14 @@ func useCommandContexts(t *testing.T) { previous := conf.Contexts conf.Contexts = map[string]interface{}{ commandContext: map[string]interface{}{ - "enable_banners": true, - "banner_command_categories": []interface{}{banner.CategoryBilling, banner.CategoryTrial}, - "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + "banner": map[string]interface{}{ + "enabled": true, + "command_categories": []interface{}{banner.CategoryBilling, banner.CategoryTrial}, + "cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + }, }, refusedContext: map[string]interface{}{ - "enable_banners": true, + "banner": map[string]interface{}{"enabled": true}, }, noBannerContext: map[string]interface{}{}, } @@ -554,12 +556,14 @@ func TestApplyCommandToAnOrganization(t *testing.T) { conf := config.GetConfig() conf.Contexts[refusedContext] = map[string]interface{}{ - "enable_banners": true, - "banner_command_categories": []interface{}{banner.CategoryBilling}, - "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + "banner": map[string]interface{}{ + "enabled": true, + "command_categories": []interface{}{banner.CategoryBilling}, + "cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + }, } t.Cleanup(func() { - conf.Contexts[refusedContext] = map[string]interface{}{"enable_banners": true} + conf.Contexts[refusedContext] = map[string]interface{}{"banner": map[string]interface{}{"enabled": true}} }) // Replay reaches a previously skipped member after its context allows diff --git a/model/banner/trigger.go b/model/banner/trigger.go index 63f2a390990..f3263c41ff3 100644 --- a/model/banner/trigger.go +++ b/model/banner/trigger.go @@ -55,7 +55,7 @@ func refreshQuotaAt(domain string, used int64) error { } // Off by default. Turning the switch back off stops the writes but leaves // the documents already materialized: a rollback needs a cleanup too. - if !inst.HasBannersEnabled() { + if !inst.BannerSettings().Enabled { return nil } @@ -110,7 +110,7 @@ func refreshCommandsAt(domain string) error { if err != nil { return err } - if !inst.HasBannersEnabled() { + if !inst.BannerSettings().Enabled { return nil } diff --git a/model/instance/instance.go b/model/instance/instance.go index 056ef415286..77b6e079fcb 100644 --- a/model/instance/instance.go +++ b/model/instance/instance.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path" + "slices" "strings" "time" @@ -30,6 +31,7 @@ import ( "github.com/cozy/cozy-stack/pkg/logger" "github.com/cozy/cozy-stack/pkg/prefixer" "github.com/cozy/cozy-stack/pkg/realtime" + "github.com/go-viper/mapstructure/v2" "github.com/golang-jwt/jwt/v5" "github.com/spf13/afero" ) @@ -889,45 +891,40 @@ func (i *Instance) MovedError() *jsonapi.Error { return &jerr } -// HasBannersEnabled reports whether platform banners are materialized for this -// instance. Off unless the context turns it on, so the rules can ship before -// the clients that render them. -func (i *Instance) HasBannersEnabled() bool { - if ctxSettings, ok := i.SettingsContext(); ok { - if enabled, ok := ctxSettings["enable_banners"].(bool); ok { - return enabled - } +// BannerSettings is the banner block of a context's settings. +type BannerSettings struct { + // Enabled materializes platform banners for the instance. Off unless the + // context turns it on, so the rules can ship before the clients that + // render them. + Enabled bool `mapstructure:"enabled"` + // CommandCategories are the categories the banner command queue may + // write. "*" allows every category the command validation accepts. + CommandCategories []string `mapstructure:"command_categories"` + // CTAHosts are the hosts a banner command's call to action may link to. + // There is no wildcard: it would let a command send users anywhere. + CTAHosts []string `mapstructure:"cta_hosts"` +} + +// BannerSettings returns the banner block of the instance's context. A missing +// or malformed block leaves banners off. +func (i *Instance) BannerSettings() BannerSettings { + var settings BannerSettings + ctxSettings, ok := i.SettingsContext() + if !ok || mapstructure.Decode(ctxSettings["banner"], &settings) != nil { + return BannerSettings{} } - return false + return settings } -// AllowsBannerCategory reports whether the context settings allow the banner -// command queue to write a category on this instance. -func (i *Instance) AllowsBannerCategory(category string) bool { - return i.contextListHas("banner_command_categories", category) +// AllowsCategory reports whether the banner command queue may write a category. +func (s BannerSettings) AllowsCategory(category string) bool { + return slices.Contains(s.CommandCategories, "*") || slices.Contains(s.CommandCategories, category) } -// AllowsBannerCTAHost reports whether the context settings allow a banner -// command's call to action to link to this host. -func (i *Instance) AllowsBannerCTAHost(host string) bool { - return i.contextListHas("banner_cta_hosts", host) -} - -func (i *Instance) contextListHas(key, value string) bool { - ctxSettings, ok := i.SettingsContext() - if !ok { - return false - } - list, ok := ctxSettings[key].([]interface{}) - if !ok { - return false - } - for _, item := range list { - if s, ok := item.(string); ok && s == value { - return true - } - } - return false +// AllowsCTAHost reports whether a banner command's call to action may link to +// this host. +func (s BannerSettings) AllowsCTAHost(host string) bool { + return slices.Contains(s.CTAHosts, host) } func (i *Instance) HasPremiumLinksEnabled() bool { diff --git a/model/instance/instance_test.go b/model/instance/instance_test.go index 36133afd9f5..ffe7856c250 100644 --- a/model/instance/instance_test.go +++ b/model/instance/instance_test.go @@ -199,4 +199,42 @@ func TestInstance(t *testing.T) { assert.False(t, inst.IsOrganizationInstance()) }) }) + + t.Run("BannerSettings", func(t *testing.T) { + cfg := config.GetConfig() + was := cfg.Contexts + defer func() { cfg.Contexts = was }() + cfg.Contexts = map[string]interface{}{ + "banner-all": map[string]interface{}{ + "banner": map[string]interface{}{ + "enabled": true, + "command_categories": []interface{}{"*"}, + "cta_hosts": []interface{}{"*"}, + }, + }, + "banner-listed": map[string]interface{}{ + "banner": map[string]interface{}{ + "enabled": true, + "command_categories": []interface{}{"billing"}, + "cta_hosts": []interface{}{"manager.example.org"}, + }, + }, + "banner-malformed": map[string]interface{}{ + "banner": map[string]interface{}{"enabled": "yes"}, + }, + } + + all := (&instance.Instance{ContextName: "banner-all"}).BannerSettings() + assert.True(t, all.Enabled) + assert.True(t, all.AllowsCategory("trial")) + assert.False(t, all.AllowsCTAHost("evil.example"), "CTA hosts have no wildcard") + + listed := (&instance.Instance{ContextName: "banner-listed"}).BannerSettings() + assert.True(t, listed.AllowsCategory("billing")) + assert.False(t, listed.AllowsCategory("trial")) + assert.True(t, listed.AllowsCTAHost("manager.example.org")) + + assert.False(t, (&instance.Instance{ContextName: "banner-malformed"}).BannerSettings().Enabled) + assert.False(t, (&instance.Instance{ContextName: "no-such-context"}).BannerSettings().Enabled) + }) } diff --git a/pkg/rabbitmq/banner_commands_test.go b/pkg/rabbitmq/banner_commands_test.go index cb02abdd30d..69b375c2e37 100644 --- a/pkg/rabbitmq/banner_commands_test.go +++ b/pkg/rabbitmq/banner_commands_test.go @@ -41,9 +41,11 @@ func TestBannerCommandsThroughTheBroker(t *testing.T) { previous := conf.Contexts conf.Contexts = map[string]interface{}{ contextName: map[string]interface{}{ - "enable_banners": true, - "banner_command_categories": []interface{}{"billing"}, - "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + "banner": map[string]interface{}{ + "enabled": true, + "command_categories": []interface{}{"billing"}, + "cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + }, }, } t.Cleanup(func() { conf.Contexts = previous }) diff --git a/pkg/rabbitmq/handlers_test.go b/pkg/rabbitmq/handlers_test.go index c81482a9554..ebbf86a9cba 100644 --- a/pkg/rabbitmq/handlers_test.go +++ b/pkg/rabbitmq/handlers_test.go @@ -109,9 +109,11 @@ func TestBannerCommandHandler(t *testing.T) { previous := conf.Contexts conf.Contexts = map[string]interface{}{ contextName: map[string]interface{}{ - "enable_banners": true, - "banner_command_categories": []interface{}{"billing"}, - "banner_cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + "banner": map[string]interface{}{ + "enabled": true, + "command_categories": []interface{}{"billing"}, + "cta_hosts": []interface{}{"manager.example.org", "twake.app"}, + }, }, } t.Cleanup(func() { conf.Contexts = previous })