R-1254 (controller half): escrow claim/confirm outwait the agent (60 s); an unknown confirm outcome promises nothing
gates / gates (push) Successful in 28s
gates / gates (push) Successful in 28s
504 from the agent (it cannot learn which package the hub holds) or a failed call -> api.escrow.confirm_store_unknown (hu+en); only 502 keeps 'your previous code still works'. Tests TestR1254_* red before the fix. Commit only.
This commit is contained in:
1 parent
342a394bff
commit
e33cffaee1
10 files changed
+133
-4
No files matched your search
@@ -1071,6 +1071,18 @@ func (c *Client) RemoveNetStorage(ctx context.Context, name string) error {
|
||||
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
|
||||
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
|
||||
func (c *Client) postWithStatus(ctx context.Context, path string, body any) (apiResponse, int, error) {
|
||||
return c.postWithStatusOn(c.hc, ctx, path, body)
|
||||
}
|
||||
|
||||
// postWithStatusTimeout is postWithStatus with its own overall timeout (the escrow claim and confirm, R-1254). It shares
|
||||
// the pinned transport.
|
||||
func (c *Client) postWithStatusTimeout(ctx context.Context, path string, body any, timeout time.Duration) (apiResponse, int, error) {
|
||||
hc := *c.hc
|
||||
hc.Timeout = timeout
|
||||
return c.postWithStatusOn(&hc, ctx, path, body)
|
||||
}
|
||||
|
||||
func (c *Client) postWithStatusOn(hc *http.Client, ctx context.Context, path string, body any) (apiResponse, int, error) {
|
||||
var env apiResponse
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
@@ -1083,7 +1095,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := c.hc.Do(req)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
|
||||
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
|
||||
@@ -108,8 +109,15 @@ type EscrowClaim struct {
|
||||
//
|
||||
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
|
||||
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
|
||||
// escrowCallTimeout bounds the claim and the confirm (R-1254 H2). The agent may take its whole upload timeout (30 s)
|
||||
// plus its question to the hub (15 s) before it answers; the client's own 15 s used to abandon the call while the
|
||||
// agent went on — a claim abandoned that way could still store the package, and its code reached nobody. 60 s
|
||||
// outlasts both (TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload; the agent side: escrowUploadTimeout,
|
||||
// escrowHoldTimeout in felhom-agent internal/localapi/escrow_ceremony.go — if either grows, this must too).
|
||||
var escrowCallTimeout = 60 * time.Second
|
||||
|
||||
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
|
||||
env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true}, escrowCallTimeout)
|
||||
if err != nil {
|
||||
return EscrowClaim{}, status, err
|
||||
}
|
||||
@@ -141,7 +149,7 @@ func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, err
|
||||
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
|
||||
// waiting). The words are part of the recovery code — never logged.
|
||||
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
|
||||
env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words}, escrowCallTimeout)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-1254 H2: the agent's claim/confirm may take its whole upload timeout (30 s) plus the hub question (15 s) before it
|
||||
// answers. The controller's agent client gives up at 15 s — and a claim the controller abandons can still complete on
|
||||
// the agent: the hub then holds a package whose code nobody saw. The two calls carry their own, longer timeout.
|
||||
// RED-PROOF: send claim/confirm through the plain 15 s client again → the slow agent below times them out → FAILS.
|
||||
func TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload(t *testing.T) {
|
||||
if escrowCallTimeout < 45*time.Second+10*time.Second {
|
||||
t.Fatalf("escrowCallTimeout %s must outlast the agent's upload (30 s) + hub question (15 s) with room", escrowCallTimeout)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
|
||||
})
|
||||
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
c.hc.Timeout = 100 * time.Millisecond // the client's own short timeout, scaled down
|
||||
old := escrowCallTimeout
|
||||
escrowCallTimeout = 5 * time.Second
|
||||
defer func() { escrowCallTimeout = old }()
|
||||
if claim, st, err := c.EscrowCeremonyClaim(context.Background()); err != nil || st != http.StatusOK || claim.Code != "a-b-c" {
|
||||
t.Fatalf("a slow claim was abandoned: %+v %d %v", claim, st, err)
|
||||
}
|
||||
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
|
||||
t.Fatalf("a slow confirm was abandoned: %d %v", st, err)
|
||||
}
|
||||
}
|
||||
@@ -2672,6 +2672,7 @@
|
||||
"api.escrow.confirm_expired": "The time to type the new code back has run out — your previous code still works. Start the process again.",
|
||||
"api.escrow.confirm_lost": "The new code no longer waits for confirmation — your previous code still works. Start the process again.",
|
||||
"api.escrow.confirm_store_failed": "The new code could not be saved — your previous code still works. Try again.",
|
||||
"api.escrow.confirm_store_unknown": "We cannot tell yet whether the new code was saved — the server did not answer in time. Keep both codes (the old one and the new one) and try again.",
|
||||
"api.escrow.confirm_words_mismatch": "The words do not match — check the code you wrote down.",
|
||||
"api.share.form_invalid": "Invalid form — reload the page.",
|
||||
"api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute",
|
||||
|
||||
@@ -2660,6 +2660,7 @@
|
||||
"api.escrow.confirm_expired": "Lejárt az idő az új kód visszaírására — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
|
||||
"api.escrow.confirm_lost": "Az új kód már nem vár megerősítésre — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
|
||||
"api.escrow.confirm_store_failed": "Az új kódot nem sikerült elmenteni — a régi kódod továbbra is érvényes. Próbáld újra.",
|
||||
"api.escrow.confirm_store_unknown": "Nem tudjuk biztosan, hogy az új kód mentése sikerült-e — a központ nem válaszolt időben. Őrizd meg mindkét kódot (a régit és az újat is), és próbáld újra.",
|
||||
"api.escrow.confirm_words_mismatch": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.",
|
||||
"api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.",
|
||||
"api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva",
|
||||
|
||||
@@ -352,6 +352,12 @@ func (s *Server) escrowConfirmAPIHandler(w http.ResponseWriter, r *http.Request)
|
||||
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_expired"))
|
||||
case http.StatusNotFound:
|
||||
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_lost"))
|
||||
case http.StatusGatewayTimeout, 0:
|
||||
// R-1254 H2: the agent could not learn which package the hub holds (504), or this call itself failed (0 — it may
|
||||
// have timed out while the agent still stored the package). Either way the new package MAY be on the hub: no
|
||||
// promise about the previous code. Only 502 (the hub holds another package) keeps the store-failed sentence.
|
||||
s.logger.Printf("[WARN] [web] escrow confirm outcome unknown (status %d)", status)
|
||||
escrowJSON(w, http.StatusGatewayTimeout, nil, s.msg(r, "api.escrow.confirm_store_unknown"))
|
||||
default:
|
||||
s.logger.Printf("[WARN] [web] escrow confirm failed (status %d)", status)
|
||||
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.confirm_store_failed"))
|
||||
|
||||
@@ -93,7 +93,9 @@ func TestR1151_ConfirmAnswersInTheBoxLanguage(t *testing.T) {
|
||||
{http.StatusGone, http.StatusGone, true, "Lejárt az idő", "has run out"},
|
||||
{http.StatusNotFound, http.StatusGone, true, "már nem vár megerősítésre", "no longer waits"},
|
||||
{http.StatusBadGateway, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
|
||||
{0, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
|
||||
// R-1254 H2 (2026-10-28): a failed call (0) or an agent that cannot learn the outcome (504) promises nothing.
|
||||
{0, http.StatusGatewayTimeout, false, "Nem tudjuk biztosan", "cannot tell yet"},
|
||||
{http.StatusGatewayTimeout, http.StatusGatewayTimeout, false, "Nem tudjuk biztosan", "cannot tell yet"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
for _, lang := range []string{"hu", "en"} {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1254 H2 (the household's sentence): a confirm whose upload answer is late or lost must not promise „your previous
|
||||
// code still works" — the hub may hold the new package. Since agent 0.168.0 the agent asks the hub and answers 502 only
|
||||
// when the hub holds ANOTHER package; 504 = unknown. A controller-side transport failure (status 0 — the call itself
|
||||
// timed out while the agent may still store) is unknown too.
|
||||
// RED-PROOF: drop the 504/0 case from escrowConfirmAPIHandler → the default sentence („a régi kódod továbbra is
|
||||
// érvényes") is shown → FAILS.
|
||||
func TestR1254_AnUnknownConfirmOutcomePromisesNothing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
}{{"agent 504", http.StatusGatewayTimeout}, {"transport failure", 0}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newEscrowWizardHarness(t)
|
||||
h.configureOffbox(t, "pending")
|
||||
h.agent.confirmStatus, h.agent.confirmErr = tc.status, errors.New("late")
|
||||
for _, lang := range []string{"hu", "en"} {
|
||||
w := postConfirm(t, h.s, lang, `{"words":["ketto","negy"]}`)
|
||||
low := strings.ToLower(w.Body.String())
|
||||
// ASCII fragments (the accented forms carry these stems): „régi kódod továbbra is érvényes" / „still works".
|
||||
if strings.Contains(low, "tovabbra is") || strings.Contains(low, "továbbra is") || strings.Contains(low, "still works") {
|
||||
t.Fatalf("%s: an unknown outcome promised the previous code: %s", lang, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(low, "mindket") && !strings.Contains(low, "mindkét") && !strings.Contains(low, "both") {
|
||||
t.Fatalf("%s: the sentence must tell the household to keep both codes: %s", lang, w.Body.String())
|
||||
}
|
||||
}
|
||||
if ceremonyStamped(t, h) {
|
||||
t.Fatal("an unknown outcome stamped the ceremony completed")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Control: the agent says the hub holds another package (502) → the old sentence, now true.
|
||||
h := newEscrowWizardHarness(t)
|
||||
h.configureOffbox(t, "pending")
|
||||
h.agent.confirmStatus, h.agent.confirmErr = http.StatusBadGateway, errors.New("not stored")
|
||||
if w := postConfirm(t, h.s, "en", `{"words":["ketto","negy"]}`); !strings.Contains(w.Body.String(), "still works") {
|
||||
t.Fatalf("502 must keep the store-failed sentence: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user