Name Temporal in the end date's refusal

The end date's field error read "An end date needs the expiration
scheduler, which is not connected." It now reads "End dates can't be
saved while Temporal is not connected.", which names the service that
whoever runs the console configures and searches for.

The bounded-grant test compares the message as html/template renders
it, apostrophe escaped, so its checks that the message is absent no
longer pass on the escaping alone.
This commit is contained in:
2026-10-24 01:44:34 -04:00
parent 2d71aa8db0
commit 67e69eed0d
5 changed files with 22 additions and 17 deletions
+2 -2
View File
@@ -164,8 +164,8 @@ The system:
While Temporal is not connected, which on a deployment without
`temporal-host` is always, a grant with **Valid until** is refused before
it is written, with "An end date needs the expiration scheduler, which is
not connected." under the field. **Manage** refuses a new or changed end
it is written, with "End dates can't be saved while Temporal is not
connected." under the field. **Manage** refuses a new or changed end
date the same way and saves a cleared one.
A grant with no `valid_until` is permanent. A grant with `valid_until` expires at that moment; when it expires, the pool returns to what it held before (see [Downgrades](#downgrades-and-the-tier-reduction-policy)).
@@ -5,6 +5,7 @@ package server
import (
"database/sql"
"html"
"net/http"
"net/url"
"strings"
@@ -18,9 +19,13 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/clientholder"
)
// needsSchedulerMessage is the end date's field error while Temporal is not
// needsTemporalMessage is the end date's field error while Temporal is not
// connected (the design's Copy table, row 6).
const needsSchedulerMessage = "An end date needs the expiration scheduler, which is not connected."
const needsTemporalMessage = "End dates can't be saved while Temporal is not connected."
// needsTemporalHTML is the message as html/template renders it, so the
// absence checks below cannot pass on the escaping alone.
var needsTemporalHTML = html.EscapeString(needsTemporalMessage)
// boundedGrantWorld is one operator, one product and the handler whose Temporal
// holder a row sets.
@@ -100,12 +105,12 @@ func checkIssuanceWithBoundRefused(t *testing.T) {
org := w.org("BG refused")
w.disconnect()
code, body, _ := w.issue(org, laterBound(30))
if code != http.StatusUnprocessableEntity || !strings.Contains(body, needsSchedulerMessage) {
t.Errorf("issuance with an end date and an empty holder: status=%d, want 422 with %q", code, needsSchedulerMessage)
if code != http.StatusUnprocessableEntity || !strings.Contains(body, needsTemporalHTML) {
t.Errorf("issuance with an end date and an empty holder: status=%d, want 422 with %q", code, needsTemporalMessage)
}
errID := `id="form-operator.enrollment.grant.issue-valid_until-error"`
if i := strings.Index(body, errID); i < 0 || !strings.Contains(body[i:min(i+300, len(body))], needsSchedulerMessage) {
t.Errorf("the refusal is not under the end date field: no %s carrying %q", errID, needsSchedulerMessage)
if i := strings.Index(body, errID); i < 0 || !strings.Contains(body[i:min(i+300, len(body))], needsTemporalHTML) {
t.Errorf("the refusal is not under the end date field: no %s carrying %q", errID, needsTemporalMessage)
}
if n := w.grantCount(org); n != 0 {
t.Errorf("a refused issuance wrote %d grants", n)
@@ -120,8 +125,8 @@ func checkIssuancePastBoundKeepsItsError(t *testing.T) {
if code != http.StatusUnprocessableEntity || !strings.Contains(body, "Choose a time in the future.") {
t.Errorf("a past end date with an empty holder: status=%d, want 422 with the past-date error", code)
}
if strings.Contains(body, needsSchedulerMessage) {
t.Errorf("a past end date with an empty holder: the body carries %q, want the past-date error alone", needsSchedulerMessage)
if strings.Contains(body, needsTemporalHTML) {
t.Errorf("a past end date with an empty holder: the body carries %q, want the past-date error alone", needsTemporalMessage)
}
if n := w.grantCount(org); n != 0 {
t.Errorf("a past end date with an empty holder: the refused issuance wrote %d grants", n)
@@ -163,8 +168,8 @@ func checkManagementWithNewBoundRefused(t *testing.T) {
}()
code, body, _ = w.h.postOrgGrant(w.h.handler.ManageGrant, org, grant, manageRow(grant, url.Values{"valid_until": {laterBound(30)}}))
}()
if code != http.StatusUnprocessableEntity || !strings.Contains(editingRow(body, grant), needsSchedulerMessage) {
t.Errorf("a new end date with an empty holder: status=%d, want the row editing with %q", code, needsSchedulerMessage)
if code != http.StatusUnprocessableEntity || !strings.Contains(editingRow(body, grant), needsTemporalHTML) {
t.Errorf("a new end date with an empty holder: status=%d, want the row editing with %q", code, needsTemporalMessage)
}
if n, bound := w.grantCount(org), w.headBound(org); n != 1 || bound != "" {
t.Errorf("a refused save left %d grants and an end date of %q, want the grant unchanged", n, bound)
@@ -184,8 +189,8 @@ func checkManagementPastBoundKeepsItsError(t *testing.T) {
if code != http.StatusUnprocessableEntity || !strings.Contains(row, "Choose a time in the future.") {
t.Errorf("a past end date with an empty holder: status=%d, want the row editing with the past-date error", code)
}
if strings.Contains(row, needsSchedulerMessage) {
t.Errorf("a past end date with an empty holder: the row carries %q, want the past-date error alone", needsSchedulerMessage)
if strings.Contains(row, needsTemporalHTML) {
t.Errorf("a past end date with an empty holder: the row carries %q, want the past-date error alone", needsTemporalMessage)
}
}
+1 -1
View File
@@ -770,7 +770,7 @@ func (e *validUntilRefusal) Error() string { return "valid until refused: " + e.
// boundNeedsTemporalMessage is the end date's field error while Temporal is
// not connected: the expiration workflow is the only thing that ends a
// bounded grant.
const boundNeedsTemporalMessage = "An end date needs the expiration scheduler, which is not connected."
const boundNeedsTemporalMessage = "End dates can't be saved while Temporal is not connected."
// logBound renders a grant's bound for the Manage log line: RFC 3339, or
// "none" for an open-ended grant.
@@ -335,7 +335,7 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| 3 | A request the session store cannot serve (action to the request's path for a GET or HEAD when `validReturnTo` accepts it, `/` otherwise; `/login` for `/callback`, whose authorization code is spent once) | heading `Sign-in unavailable`, action `Try again` |
| 4 | `POST /webhooks/stripe` before Temporal connects, the 503 body Stripe shows in its delivery log | `Temporal is not connected` |
| 5 | `core.webhook_events.error_message` of a Discourse event before Temporal connects | `start reconcile: Temporal is not connected` |
| 6 | Issue grant and grant management, the end date's field error while Temporal is not connected | `An end date needs the expiration scheduler, which is not connected.` |
| 6 | Issue grant and grant management, the end date's field error while Temporal is not connected | `End dates can't be saved while Temporal is not connected.` |
Reused unchanged: the existing "not available" answers of the Temporal-backed actions (for example `Domain verification is not available right now.`, `Site creation service is not available`, `Temporal is unavailable.`) and the recovery page's message `An unexpected error occurred. Try again.` for a session that cannot be decoded.
@@ -2,7 +2,7 @@
### Requirement: A grant with an end date needs Temporal before it is written
The expiration workflow is the only enforcement of a grant's `valid_until`. While the console has no Temporal connection, because Temporal is not configured or has not connected yet, issuing a grant with a `valid_until` bound, and saving a new or changed `valid_until` bound on a grant, SHALL be refused, after the end date has been parsed and checked so that a past or malformed date keeps its own field error, and before any grant row, decree or conferral is written, with the field error `An end date needs the expiration scheduler, which is not connected.` on the end date and the operator's other input kept. A grant without a bound SHALL be issued as before, and a save that clears a grant's bound SHALL NOT be refused, because it starts no workflow. A grant written while connected whose expiration workflow then fails to start SHALL keep the existing warning that it will not expire on its own.
The expiration workflow is the only enforcement of a grant's `valid_until`. While the console has no Temporal connection, because Temporal is not configured or has not connected yet, issuing a grant with a `valid_until` bound, and saving a new or changed `valid_until` bound on a grant, SHALL be refused, after the end date has been parsed and checked so that a past or malformed date keeps its own field error, and before any grant row, decree or conferral is written, with the field error `End dates can't be saved while Temporal is not connected.` on the end date and the operator's other input kept. A grant without a bound SHALL be issued as before, and a save that clears a grant's bound SHALL NOT be refused, because it starts no workflow. A grant written while connected whose expiration workflow then fails to start SHALL keep the existing warning that it will not expire on its own.
#### Scenario: A bounded grant before the connection is refused