Compare commits
3 Commits
05cf352a2f
...
7534ea203d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7534ea203d | |||
| c7446f2d6a | |||
| 1e759a16ec |
@@ -1,3 +1,96 @@
|
||||
## v0.202.0 — the customer is blamed only after a real attempt refused their code (2026-08-06, R-224/R-226/R-225/R-227/R-228) — MinAgent 0.126.0
|
||||
|
||||
**CAMPAIGN-11's headline defect had moved, not gone.** v0.201.0 stopped an agent that is too OLD from
|
||||
being reported as a wrong recovery code. An agent that is **stopped**, and a hub that cannot be
|
||||
**reached**, still fell through to a message about the code — measured live on 2026-08-05 with a
|
||||
**correct, current** code at **0.0299 s** and **0.0556 s**, against ~1.0 s for a genuine unseal. The
|
||||
machine had not tried, and told the customer their code was wrong.
|
||||
|
||||
**And the inverse was true at the same time (R-226).** The one message that says *"check your ten
|
||||
words"* was tested AFTER the retained-earlier-package message, so on any box that has re-escrowed —
|
||||
precisely the box whose customer has just been handed a new code — a genuine mistype could never
|
||||
reach it. Three unrelated failures got the accusation; the one that deserved it got something else.
|
||||
|
||||
**One defect from both ends: nothing on that path asked WHY it failed.** `rerr` was never inspected.
|
||||
|
||||
### The rule, and it is the whole change
|
||||
|
||||
> **The customer is blamed only after a real attempt refused their code. Every other outcome —
|
||||
> including one we cannot classify — says something else.**
|
||||
|
||||
Five classes, **from the value and never the text**:
|
||||
|
||||
| class | from | what the customer is told |
|
||||
|---|---|---|
|
||||
| hub-unreachable | 502/503 | the connection failed; **the code was NOT used** |
|
||||
| agent-unreachable | no agent verdict at all | the machine's own service is not answering; **NOT used** |
|
||||
| no-bundle | 404 | nothing is held for this machine; not about the code |
|
||||
| bundle-too-old | 409 | the code WORKED; the package predates the field |
|
||||
| **asked-and-refused** | **400** | **the only class that may mention typing** |
|
||||
| unknown | anything else | **neutral — claims neither that the code was wrong nor that it went unused** |
|
||||
|
||||
`agentapi.RecoveryRefusal` carries the status as a value; `refusalError` flattened it into a sentence,
|
||||
and a sentence is not something a caller can branch on.
|
||||
|
||||
**THE SAFE DEFAULT IS THE POINT.** `RecoveryUnknown` is the zero value, and an unrecognised status
|
||||
lands there rather than in an accusation. **That is the rule whose absence let this survive being
|
||||
fixed once.**
|
||||
|
||||
**Coupling — `MinAgent 0.126.0`.** An agent below it answers **400 for both** a fetch failure and a
|
||||
wrong code, so a 400 from one cannot be read as a refusal. `FeatureRecoveryFailureClass` withholds
|
||||
that reading and the 400 degrades to **neutral**. The gate **blocks nothing** — the unlock is
|
||||
attempted either way — it only decides whether the customer may be told to check their typing, and
|
||||
"not sure" means they may not. It heals itself when the agent updates.
|
||||
|
||||
**Elapsed time is logged** (it is what diagnosed this, and it is the cheapest tell for the operator)
|
||||
**and is never the classifier.** Time is a symptom; the status is the fact.
|
||||
|
||||
### R-225 — unknown is not zero
|
||||
|
||||
An unread store rendered `Tároló méret · 0 pillanatkép` and `Tárhelykeret: 0 / 50 GB (0%)` **directly
|
||||
above a card saying the store held backups under another key**. An SFTP listing found snapshot
|
||||
`f3d9cd67` and **12 535 KB** really there; `snapshot_count` and `repo_size_bytes` were simply ABSENT
|
||||
from `settings.json` and the zero value spoke for them. `StatsKnown` is **named**, for the same reason
|
||||
`OffsiteInventory.Empty` is — zero is what an unread store and an empty one both look like, and both
|
||||
fields are `omitempty` ints, so on disk "absent" and "0" are the same bytes. The fill bar renders only
|
||||
when the fill is known: **a 0 %-wide bar is a picture of emptiness, and a picture is a claim.** A
|
||||
*measured* zero still says zero.
|
||||
|
||||
### R-227 — the gateway error
|
||||
|
||||
**Which layer answers: traefik**, whose config this repo generates. A branded proxy page is therefore
|
||||
possible here, but traefik v3 serves no static files, so it would need a **new always-up container**
|
||||
for every 502 on the box — out of proportion to this finding, and **scoped in the report rather than
|
||||
built**. Shipped instead: the unlock posts via `fetch`, so a gateway failure is answered in Hungarian
|
||||
without leaving the page. **Progressive enhancement** — with no JS the plain POST is unchanged and
|
||||
still shows the proxy's error, and this entry says so rather than implying otherwise.
|
||||
|
||||
### R-228 — the set-aside history is visible, and honest
|
||||
|
||||
A customer who chose "I do not want the old data" was told the backups would be **kept**, and the
|
||||
move-aside did exactly that — 12 535 KB, byte-exact. The box recorded the path in
|
||||
`orphaned_renamed_to` and **a census found zero references to it in any template or handler.** It is
|
||||
surfaced now as two facts and stops.
|
||||
|
||||
**It does NOT promise the history can be reopened, and the confirmation copy was corrected for the
|
||||
same reason.** *"a helyreállítási kód nélkül többé nem lesznek megnyithatók"* implied that **with**
|
||||
the code they could be; serving a superseded package is an unbuilt link (R-199's inventory), so it
|
||||
cannot be opened by the customer, the operator, or anyone. The field's own comment called it
|
||||
*"recovery-code-recoverable"* — the same over-promise, in the code.
|
||||
|
||||
### Tests
|
||||
|
||||
Scenarios A-H at the **handler** and as **render tests per branch of each gate**. Red-proofs, each
|
||||
demonstrated failing and restored: delete the 502 case (A) - remove the mistype clause (C) - default
|
||||
to the accusation (D) - route an instant transport failure to the typing message (E) - remove the
|
||||
`StatsKnown` guards (F) - delete the set-aside block (H).
|
||||
|
||||
**Two existing tests encoded the defect and were corrected rather than deleted.** The web fake
|
||||
returned a **bare** error for "wrong code" — which is the shape of a failure we cannot classify, and
|
||||
now correctly renders the neutral message; saying "wrong code" in a test requires saying it the way
|
||||
the agent says it. And R-222's test forbade **any** mention of typing on a superseded box, **half of
|
||||
which R-226 deliberately reverses**: what stays forbidden is the bare accusation, not the hint.
|
||||
|
||||
## v0.201.0 — a correct recovery code is never called wrong again (2026-08-05, CAMPAIGN-11) — MinAgent 0.125.0
|
||||
|
||||
CAMPAIGN-11 walked the whole recovery journey end to end for the first time. **The data came back
|
||||
|
||||
@@ -3,6 +3,7 @@ package agentapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
@@ -132,8 +133,11 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
|
||||
if perr != nil {
|
||||
return "", "", perr
|
||||
}
|
||||
if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil {
|
||||
return "", "", rerr
|
||||
// R-224: this route's refusal keeps its STATUS as a value. `refusalError` flattens status into a
|
||||
// sentence, and a sentence is not something a caller can branch on — which is exactly how a failed
|
||||
// fetch and a wrong recovery code came to produce one customer-facing message.
|
||||
if status < 200 || status > 299 || !env.OK {
|
||||
return "", "", &RecoveryRefusal{Status: status, Reason: truncateErr(env.Error, 300)}
|
||||
}
|
||||
var out struct {
|
||||
ResticRepoPassword string `json:"restic_repo_password"`
|
||||
@@ -147,3 +151,109 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
|
||||
}
|
||||
return out.ResticRepoPassword, out.ResticPwSHA256, nil
|
||||
}
|
||||
|
||||
// ── R-224 — CLASSIFYING A FAILED UNLOCK ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// CAMPAIGN-11 measured what happens without this. On 2026-08-05, with a CORRECT current recovery
|
||||
// code: the hub firewalled off returned the customer "this code does not open your package" in
|
||||
// 0.0556 s, and this agent stopped returned the same in 0.0299 s — against ~1.0 s for a genuine
|
||||
// unseal. Neither attempted one. The failure path had exactly two branches, both of them statements
|
||||
// about the customer's code, and `rerr` was never inspected.
|
||||
//
|
||||
// The rule this type exists to enforce: **the customer is blamed only after a real attempt refused
|
||||
// their code.** Everything else — including anything we cannot classify — says something else.
|
||||
|
||||
// RecoveryRefusal is the agent's refusal of an unlock, carrying the STATUS as a value so callers
|
||||
// classify on it rather than on the sentence. The message keeps `refusalError`'s shape so operator
|
||||
// logs read as they did.
|
||||
type RecoveryRefusal struct {
|
||||
Status int
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *RecoveryRefusal) Error() string {
|
||||
reason := e.Reason
|
||||
if reason == "" {
|
||||
reason = "(no reason in agent response)"
|
||||
}
|
||||
return fmt.Sprintf("agentapi: POST /escrow/recover-offsite-password: HTTP %d: %s", e.Status, reason)
|
||||
}
|
||||
|
||||
// RecoveryFailure is what went wrong, as far as it can be known.
|
||||
type RecoveryFailure int
|
||||
|
||||
const (
|
||||
// RecoveryUnknown — the cause could not be determined. **The safe default**, and deliberately the
|
||||
// zero value: a new status, a transport shape nobody anticipated, or an agent too old to
|
||||
// distinguish fetch from refusal all land here, and none of them may blame the customer.
|
||||
RecoveryUnknown RecoveryFailure = iota
|
||||
// RecoveryHubUnreachable — the agent answered, and it could not FETCH the sealed package: the hub
|
||||
// refused, was unreachable, or recovery is not configured on this agent. **The code was not used.**
|
||||
RecoveryHubUnreachable
|
||||
// RecoveryAskedAndRefused — the bundle was fetched and the code did not open it. The ONLY class
|
||||
// from which the customer may be told to check their typing.
|
||||
RecoveryAskedAndRefused
|
||||
// RecoveryNoBundle — the hub holds no sealed package for this host at all.
|
||||
RecoveryNoBundle
|
||||
// RecoveryBundleTooOld — the bundle opened but predates the repository-password field.
|
||||
RecoveryBundleTooOld
|
||||
// RecoveryAgentUnreachable — the machine's own in-house service never answered, so there is no
|
||||
// agent verdict at all. **The code was not used.** Distinct from RecoveryHubUnreachable because
|
||||
// it is a different fault, with different words and a different remedy.
|
||||
RecoveryAgentUnreachable
|
||||
)
|
||||
|
||||
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
|
||||
//
|
||||
// ⚠ `trustRefusal` is the agent-version gate and it is not optional. An agent older than v0.126.0
|
||||
// answers **400 for BOTH** a fetch failure and a wrong code, so a 400 from one cannot be read as
|
||||
// "the code was refused" — it means "one of two things, and we cannot tell which". Pass false there
|
||||
// and the 400 degrades to RecoveryUnknown, which is neutral. That degradation is the point: it is
|
||||
// safe, it is silent, and it heals itself when the agent updates.
|
||||
func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
|
||||
if err == nil {
|
||||
return RecoveryUnknown
|
||||
}
|
||||
var ref *RecoveryRefusal
|
||||
if !errors.As(err, &ref) {
|
||||
// Not a refusal at all — the request never produced an agent verdict (dial failure, TLS,
|
||||
// timeout, or the channel could not be built). The machine could not even ASK its own service,
|
||||
// which is a different sentence from "the hub was unreachable" and a different thing to fix.
|
||||
return RecoveryAgentUnreachable
|
||||
}
|
||||
switch ref.Status {
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
// 502 is agent >= v0.126.0's "the sealed bundle could not be fetched". 503 is its
|
||||
// "recovery is not configured on this agent (no hub client)". Neither used the code.
|
||||
return RecoveryHubUnreachable
|
||||
case http.StatusNotFound:
|
||||
return RecoveryNoBundle
|
||||
case http.StatusConflict:
|
||||
return RecoveryBundleTooOld
|
||||
case http.StatusBadRequest:
|
||||
if trustRefusal {
|
||||
return RecoveryAskedAndRefused
|
||||
}
|
||||
return RecoveryUnknown
|
||||
default:
|
||||
return RecoveryUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// String names the class for the operator log. The customer never sees these words.
|
||||
func (f RecoveryFailure) String() string {
|
||||
switch f {
|
||||
case RecoveryHubUnreachable:
|
||||
return "hub-unreachable"
|
||||
case RecoveryAgentUnreachable:
|
||||
return "agent-unreachable"
|
||||
case RecoveryAskedAndRefused:
|
||||
return "asked-and-refused"
|
||||
case RecoveryNoBundle:
|
||||
return "no-bundle"
|
||||
case RecoveryBundleTooOld:
|
||||
return "bundle-too-old"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,21 @@ const FeatureBackupAgeState Feature = "backup_age_state"
|
||||
// never have worked is attributed to the code.
|
||||
const FeatureOffsiteKeyRecovery Feature = "offsite_key_recovery"
|
||||
|
||||
// FeatureRecoveryFailureClass is agent v0.126.0's SPLIT of a failed unlock into distinguishable
|
||||
// statuses (R-224): 502 the sealed bundle could not be FETCHED · 400 it was fetched and the code was
|
||||
// refused · 404 no bundle · 409 the bundle predates the repository-password field.
|
||||
//
|
||||
// ⚠ WHAT THIS GATE ACTUALLY GUARDS is the meaning of **400**, and nothing else. An agent older than
|
||||
// v0.126.0 answers 400 for BOTH a fetch failure and a wrong code — one status, one sentence, two
|
||||
// situations — so on such an agent a 400 cannot be read as "the code was refused". It means "one of
|
||||
// two things and we cannot tell which", which is `RecoveryUnknown`, which is neutral.
|
||||
//
|
||||
// So this gate does not block anything and has no fail-closed behaviour to get wrong: the unlock is
|
||||
// attempted either way (FeatureOffsiteKeyRecovery already decides THAT). It only decides whether the
|
||||
// customer may be told to check their typing. Unknown → they may not. **That is the safe direction,
|
||||
// and it heals itself the moment the agent updates.**
|
||||
const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
|
||||
|
||||
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
||||
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||
type SupportState int
|
||||
@@ -122,6 +137,10 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
FeatureOffsiteKeyRecovery: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
// Same POST route, same reason it cannot be probed — the decision falls to the VERSION path.
|
||||
FeatureRecoveryFailureClass: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
}
|
||||
|
||||
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||
@@ -145,6 +164,9 @@ var featureMinAgent = map[Feature]string{
|
||||
// R-199 links 7–8: POST /escrow/recover-offsite-password. R-216 — this row is the whole reason a
|
||||
// correct recovery code can no longer be reported as wrong on an agent that cannot answer.
|
||||
FeatureOffsiteKeyRecovery: "0.125.0",
|
||||
|
||||
// R-224 — the four-way status split of a failed unlock.
|
||||
FeatureRecoveryFailureClass: "0.126.0",
|
||||
}
|
||||
|
||||
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
||||
|
||||
@@ -496,6 +496,7 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
|
||||
// "never succeeded" every time the hub re-pushes the descriptor.
|
||||
tgt.LastSuccess = cur.LastSuccess
|
||||
tgt.RepoSizeHuman, tgt.RepoSizeBytes, tgt.SnapshotCount = cur.RepoSizeHuman, cur.RepoSizeBytes, cur.SnapshotCount
|
||||
tgt.StatsKnown = cur.StatsKnown // R-225: carry the KNOWN-ness with the numbers
|
||||
}
|
||||
if tgt.EscrowState != "escrowed" {
|
||||
tgt.EscrowState = "pending"
|
||||
@@ -883,6 +884,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
}
|
||||
o.LastError = ""
|
||||
o.SnapshotCount = snapshots
|
||||
o.StatsKnown = true // R-225: measured, even if the answer is zero
|
||||
o.EnlargedBlocked = blockedNames // replace each run (sorted); empty slice clears it
|
||||
var warns []string
|
||||
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
|
||||
@@ -1419,6 +1421,7 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int
|
||||
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.RepoSizeHuman = human
|
||||
o.RepoSizeBytes = st.TotalSize
|
||||
o.StatsKnown = true // R-225
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,22 @@ type OffboxTarget struct {
|
||||
// gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe).
|
||||
RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"`
|
||||
SnapshotCount int `json:"snapshot_count,omitempty"`
|
||||
// StatsKnown — R-225. Whether SnapshotCount / RepoSizeBytes above were ever actually READ from the
|
||||
// repository, as opposed to never having been measured.
|
||||
//
|
||||
// ⚠ IT IS NAMED RATHER THAN INFERRED FROM THE COUNTS, for the same reason `OffsiteInventory.Empty`
|
||||
// is: **zero is what an unread store and a genuinely empty one both look like**, and the two are
|
||||
// opposite news. Both fields are `omitempty` ints, so on the wire and on disk "absent" and "0" are
|
||||
// the same bytes — the distinction cannot be recovered downstream and has to be carried.
|
||||
//
|
||||
// Measured live on 2026-08-05 (CAMPAIGN-11, R-225): after a rebuild the box rendered
|
||||
// „Tároló méret · 0 pillanatkép" and „Tárhelykeret: 0 / 50 GB (0%)" — directly above a card saying
|
||||
// the store held backups made under another key. An SFTP listing found snapshot `f3d9cd67` and
|
||||
// 12 535 KB really there. The keys were simply ABSENT from settings.json and the zero value spoke
|
||||
// for them.
|
||||
//
|
||||
// Set true by the run that reads the stats, whatever it reads — a true zero is knowledge too.
|
||||
StatsKnown bool `json:"stats_known,omitempty"`
|
||||
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
|
||||
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
|
||||
LastWarning string `json:"last_warning,omitempty"`
|
||||
@@ -262,7 +278,19 @@ type OffboxTarget struct {
|
||||
// OrphanedAt is the RFC3339 stamp of the orphan detection (drives the card copy).
|
||||
OrphanedAt string `json:"orphaned_at,omitempty"`
|
||||
// OrphanedRenamedTo records the move-aside path of the last reset (e.g. <repo>.orphaned-20260717),
|
||||
// so the card/log can name where the old (recovery-code-recoverable) history was set aside.
|
||||
// so the card/log can name where the old history was set aside.
|
||||
//
|
||||
// ⚠ R-228 (2026-08-06) — THIS WAS WRITTEN AND READ BY NOBODY, and this comment used to call the
|
||||
// set-aside history "recovery-code-recoverable", which is **not true today**: serving a superseded
|
||||
// blob is an unbuilt link (R-199's inventory), so the old store cannot be opened by the customer,
|
||||
// the operator, or anyone else. Measured on 2026-08-05 (CAMPAIGN-11 F7): after a customer chose to
|
||||
// set their old backups aside, 12 535 KB sat at this exact path and `/backups/remote` mentioned it
|
||||
// nowhere — a census found zero references to this field in any template or handler. The screen
|
||||
// promised the data was kept and then showed nothing.
|
||||
//
|
||||
// It is surfaced now, as a plain statement that an earlier history is KEPT and NOT DELETED. It must
|
||||
// NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
|
||||
// that turns out false is worse on this surface than saying less.
|
||||
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// ── SCENARIO F (R-225) — AN UNREAD STORE SAYS "UNKNOWN", NEVER "ZERO" ───────────────────────────
|
||||
//
|
||||
// Measured live on 2026-08-05 (CAMPAIGN-11): after a rebuild this page rendered
|
||||
//
|
||||
// Tároló méret · 0 pillanatkép Tárhelykeret: 0 / 50 GB (0%)
|
||||
//
|
||||
// directly above a card stating the store contains backups made under another key. An SFTP listing
|
||||
// of the remote — read-only, no decryption — found snapshot `f3d9cd67` and **12 535 KB** really
|
||||
// there. `snapshot_count` and `repo_size_bytes` were simply ABSENT from settings.json, and the zero
|
||||
// value spoke for them.
|
||||
//
|
||||
// This is R-217's defect class one card over: a field whose zero is indistinguishable from a real
|
||||
// measurement, defaulted past on an unknown path. `StatsKnown` is the named state, for the same
|
||||
// reason `OffsiteInventory.Empty` is named rather than inferred from `len(Apps)==0`.
|
||||
//
|
||||
// Render tests per branch of the gate, because a template gate without one is the v0.70.1 lesson.
|
||||
|
||||
func remoteStatsData(known bool, snaps int, human string) map[string]interface{} {
|
||||
d := splitTestData()
|
||||
d["Offbox"] = &settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
|
||||
LastStatus: "error", EscrowState: "escrowed", QuotaGB: 50,
|
||||
SnapshotCount: snaps, RepoSizeHuman: human, StatsKnown: known,
|
||||
}
|
||||
d["OffboxQuotaPct"] = 0
|
||||
return d
|
||||
}
|
||||
|
||||
// The branch the finding was measured on: nothing has ever been read.
|
||||
func TestBackupsRemote_UnknownStoreStatsSayUnknownNotZero(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_remote", remoteStatsData(false, 0, ""))
|
||||
|
||||
// RED-PROOF: set StatsKnown true in this fixture (or drop the template guard) → the page renders
|
||||
// "0 pillanatkép" and "0 / 50 GB (0%)" again → this FAILS. Demonstrated failing before it was kept.
|
||||
if strings.Contains(html, "0 pillanatkép") {
|
||||
t.Fatalf("R-225 RETURNED: an unread store reports a snapshot COUNT of zero")
|
||||
}
|
||||
if !strings.Contains(html, "a pillanatképek száma még ismeretlen") {
|
||||
t.Fatalf("an unread store must say the count is not known")
|
||||
}
|
||||
if strings.Contains(html, "0 / 50 GB") {
|
||||
t.Fatalf("R-225 RETURNED: an unread store reports a used figure of zero")
|
||||
}
|
||||
if !strings.Contains(html, "még nem tudjuk, mennyi van a tárolóban") {
|
||||
t.Fatalf("an unread store must say the used figure is not known")
|
||||
}
|
||||
// A 0%-wide fill bar is a PICTURE of emptiness, and a picture is a claim. Scoped to the quota
|
||||
// bar's own container — the page has other zero-width elements and a bare "width:0%" search
|
||||
// would pass or fail for unrelated reasons.
|
||||
if bar := between(html, `id="offbox-quota-bar"`, "</div>\n </div>"); strings.Contains(bar, "background:var(--border") {
|
||||
t.Fatalf("the fill bar rendered over an unread store — a 0%% bar asserts emptiness")
|
||||
}
|
||||
}
|
||||
|
||||
// The other branch: a store that WAS read and is genuinely empty must still say zero. Without this,
|
||||
// the fix could be "never show a number", which loses real information.
|
||||
func TestBackupsRemote_KnownEmptyStoreStillSaysZero(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 0, ""))
|
||||
|
||||
if !strings.Contains(html, "0 pillanatkép") {
|
||||
t.Fatalf("a store that was READ and holds nothing must say zero — that is knowledge")
|
||||
}
|
||||
if strings.Contains(html, "még ismeretlen") {
|
||||
t.Fatalf("a measured zero must not be dressed up as unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// And a store with real content renders it unchanged.
|
||||
func TestBackupsRemote_KnownNonEmptyStoreRendersTheNumbers(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 2, "12.0 MB"))
|
||||
|
||||
if !strings.Contains(html, "2 pillanatkép") {
|
||||
t.Fatalf("a measured count must render")
|
||||
}
|
||||
if !strings.Contains(html, "12.0 MB / 50 GB") {
|
||||
t.Fatalf("a measured size must render against the quota")
|
||||
}
|
||||
if strings.Contains(html, "még ismeretlen") || strings.Contains(html, "még nem tudjuk") {
|
||||
t.Fatalf("measured stats must not read as unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// between returns the slice of s after the first `from` and before the next `to` (empty when either
|
||||
// marker is missing) — so an assertion can be scoped to one card instead of the whole page.
|
||||
func between(s, from, to string) string {
|
||||
i := strings.Index(s, from)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := s[i+len(from):]
|
||||
if j := strings.Index(rest, to); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// ── SCENARIO H (R-228) — THE SET-ASIDE HISTORY IS VISIBLE AND HONESTLY DESCRIBED ────────────────
|
||||
//
|
||||
// Measured 2026-08-05 (CAMPAIGN-11 F7): a customer chose "I do not want the old data", was told the
|
||||
// backups would be KEPT and not deleted, and the move-aside did exactly that — 12 535 KB, byte-exact,
|
||||
// at `/home/felhom-repo.orphaned-20260805`. The box recorded the path in `orphaned_renamed_to` and a
|
||||
// census found **zero** references to it in any template or handler. The promise was kept and shown
|
||||
// to nobody.
|
||||
func TestBackupsRemote_SetAsideHistoryIsSurfaced(t *testing.T) {
|
||||
d := splitTestData()
|
||||
d["Offbox"] = &settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
|
||||
LastStatus: "ok", EscrowState: "escrowed", StatsKnown: true,
|
||||
OrphanedRenamedTo: "/home/felhom-repo.orphaned-20260805",
|
||||
}
|
||||
html := renderBackupPage(t, "backups_remote", d)
|
||||
|
||||
// RED-PROOF: delete the {{if .Offbox.OrphanedRenamedTo}} block → this FAILS, and the set-aside
|
||||
// history is invisible again. Demonstrated failing before this test was kept.
|
||||
if !strings.Contains(html, "félre vannak téve") {
|
||||
t.Fatalf("R-228 RETURNED: the set-aside history is not mentioned at all")
|
||||
}
|
||||
if !strings.Contains(html, "nem töröltük") {
|
||||
t.Fatalf("the customer must be told it was NOT deleted — that is the promise being kept")
|
||||
}
|
||||
// §7.6 — it must NOT promise the history can be reopened. It cannot be, by anyone, today.
|
||||
for _, forbidden := range []string{"visszaállítható lehet", "vissza tudod állítani", "megnyithatod", "kóddal később"} {
|
||||
if strings.Contains(html, forbidden) {
|
||||
t.Errorf("the set-aside notice promises the history can be reopened (%q) — the read path does not exist", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a box that never set anything aside must not claim it did.
|
||||
func TestBackupsRemote_NoSetAsideNoClaim(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 1, "12.0 MB"))
|
||||
if strings.Contains(html, "félre vannak téve") {
|
||||
t.Fatal("a box with no set-aside history must not claim one exists")
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tgt.LastWarning = prev.LastWarning
|
||||
tgt.EscrowState = prev.EscrowState
|
||||
tgt.RepoSizeBytes = prev.RepoSizeBytes
|
||||
tgt.StatsKnown = prev.StatsKnown // R-225: preserved with the numbers it qualifies
|
||||
tgt.EnlargedBlocked = prev.EnlargedBlocked
|
||||
}
|
||||
// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
)
|
||||
|
||||
// ── R-224 / R-226 — WHY IT FAILED DECIDES WHAT WE SAY ────────────────────────────────────────────
|
||||
//
|
||||
// CAMPAIGN-11 measured the defect these tests pin. With a CORRECT, CURRENT recovery code:
|
||||
//
|
||||
// hub firewalled off → "this code does not open your package" in 0.0556 s
|
||||
// agent stopped → the same sentence in 0.0299 s
|
||||
// genuinely wrong code → the same sentence after 1.194 / 1.004 / 1.014 s
|
||||
//
|
||||
// A real unseal costs ~1 s of scrypt, so the first two had not attempted one. Three different causes,
|
||||
// one accusation, and the failure path never inspected the error.
|
||||
//
|
||||
// Every test here asserts the EFFECT — which sentence the customer is shown — at the HANDLER, because
|
||||
// a helper-level test cannot observe a mutation that lives in the handler.
|
||||
|
||||
// namesTyping / isBareAccusation split what the old single predicate conflated. R-226 requires the
|
||||
// typing HINT on a re-escrowed box; what stays forbidden is the bare accusation that says only that.
|
||||
func namesTyping(body string) bool { return strings.Contains(body, "z szót pontosan") }
|
||||
func isBareAccusation(body string) bool { return strings.Contains(body, "nem fogadtuk el") }
|
||||
|
||||
// saysCodeWasNotUsed is the load-bearing half of every could-not-ask message: the customer must be
|
||||
// told their code was not spent, because that is what makes "keep it safe and try again" honest.
|
||||
func saysCodeWasNotUsed(body string) bool { return strings.Contains(body, "NEM használtuk fel") }
|
||||
|
||||
// refusal builds the error shape agent >= v0.126.0 returns for a given status.
|
||||
func refusal(status int, reason string) error {
|
||||
return &agentapi.RecoveryRefusal{Status: status, Reason: reason}
|
||||
}
|
||||
|
||||
// ── SCENARIO A — the hub is unreachable and the customer is not blamed ───────────────────────────
|
||||
//
|
||||
// RED-PROOF: delete the `agentapi.RecoveryHubUnreachable` case from recoveryUnlockHandler so a 502
|
||||
// falls through to the wrong-code branch → this FAILS on namesTyping, and the accusation returns
|
||||
// exactly as CAMPAIGN-11 F3 measured it. Demonstrated failing before this test was kept.
|
||||
func TestRecoveryClass_A_HubUnreachableNamesTheConnection(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.rec.failWith = refusal(502, "the sealed recovery bundle could not be fetched from the hub — the recovery code was NOT used and nothing was written")
|
||||
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
|
||||
if namesTyping(body) || isBareAccusation(body) {
|
||||
t.Fatalf("R-224 RETURNED: a hub outage is reported as a bad recovery code; got %q", firstAlert(body))
|
||||
}
|
||||
if !strings.Contains(body, "központi rendszer") {
|
||||
t.Errorf("the message must name the connection that failed; got %q", firstAlert(body))
|
||||
}
|
||||
if !saysCodeWasNotUsed(body) {
|
||||
t.Errorf("the customer must be told their code was NOT used; got %q", firstAlert(body))
|
||||
}
|
||||
// It must not invent an earlier package either — that is a different situation.
|
||||
if strings.Contains(body, "nem töröltük") {
|
||||
t.Errorf("a hub outage must not be dressed up as a retained earlier package; got %q", firstAlert(body))
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO B — the agent is stopped and the customer is not blamed ─────────────────────────────
|
||||
//
|
||||
// A dial failure produces NO agent verdict at all, so it is not a *RecoveryRefusal and classifies as
|
||||
// RecoveryAgentUnreachable. This is the F4 shape: connection refused to 169.254.253.1:8443.
|
||||
func TestRecoveryClass_B_AgentUnreachableNamesTheMachine(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.rec.failWith = errors.New(`agentapi: POST /escrow/recover-offsite-password: dial tcp 169.254.253.1:8443: connect: connection refused`)
|
||||
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
|
||||
if namesTyping(body) || isBareAccusation(body) {
|
||||
t.Fatalf("R-224 RETURNED: a stopped agent is reported as a bad recovery code; got %q", firstAlert(body))
|
||||
}
|
||||
if !strings.Contains(body, "házon belüli") {
|
||||
t.Errorf("the message must name the machine's own service; got %q", firstAlert(body))
|
||||
}
|
||||
if !saysCodeWasNotUsed(body) {
|
||||
t.Errorf("the customer must be told their code was NOT used; got %q", firstAlert(body))
|
||||
}
|
||||
// The raw technical error must never reach the customer.
|
||||
if strings.Contains(body, "dial tcp") || strings.Contains(body, "169.254") {
|
||||
t.Errorf("the raw transport error was rendered to the customer; got %q", firstAlert(body))
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO C — a genuinely wrong code says so, even on a re-escrowed box (R-226) ───────────────
|
||||
//
|
||||
// RED-PROOF: remove the mistype clause from the superseded message → this FAILS on namesTyping, and
|
||||
// the ten-words prompt is unreachable again on exactly the population most likely to need it.
|
||||
func TestRecoveryClass_C_MistypeOnAReEscrowedBoxNamesBoth(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle — nothing was written")
|
||||
if err := f.sett.SetHubEscrowSuperseded(true, "2026-08-05T15:03:14Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
|
||||
if !namesTyping(body) {
|
||||
t.Fatalf("R-226 RETURNED: a mistype on a re-escrowed box is never told to re-check the words; got %q", firstAlert(body))
|
||||
}
|
||||
if !strings.Contains(body, "nem töröltük") {
|
||||
t.Fatalf("the retained earlier package must still be named; got %q", firstAlert(body))
|
||||
}
|
||||
if isBareAccusation(body) {
|
||||
t.Fatalf("R-222 RETURNED: the bare accusation, with no mention of the retained package")
|
||||
}
|
||||
// §7.3 — it must NOT promise the earlier package can be opened.
|
||||
for _, forbidden := range []string{"vissza tudod állítani", "megnyithatod", "vissza fogod kapni"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("the message promises the earlier package can be opened (%q)", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO D — an unclassifiable failure never blames the customer ─────────────────────────────
|
||||
//
|
||||
// THE RULE THAT WAS MISSING WHEN THIS DEFECT WAS FIXED THE FIRST TIME. The default must be neutral,
|
||||
// and it must claim NEITHER that the code was wrong NOR that it went unused — neither is known.
|
||||
//
|
||||
// RED-PROOF: change the `default:` arm to render the wrong-code message → this FAILS. That mutation
|
||||
// is precisely the pre-R-224 shape, where everything unrecognised fell through to an accusation.
|
||||
func TestRecoveryClass_D_UnclassifiableIsNeutral(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"an unrecognised status", refusal(418, "something nobody anticipated")},
|
||||
{"a 400 from an agent too old to split fetch from refusal", refusal(400, "the recovery code did not open the sealed bundle, or the bundle could not be fetched")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.rec.failWith = tc.err
|
||||
if tc.name != "an unrecognised status" {
|
||||
// The OLD-agent case: a 400 may not be read as a refusal.
|
||||
f.s.SetRecoveryRefusalTrusted(func(context.Context) bool { return false })
|
||||
}
|
||||
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
|
||||
if namesTyping(body) || isBareAccusation(body) {
|
||||
t.Fatalf("an unclassifiable failure blamed the customer; got %q", firstAlert(body))
|
||||
}
|
||||
// And it must not claim the opposite either — "we did not use your code" is also a claim.
|
||||
if saysCodeWasNotUsed(body) {
|
||||
t.Fatalf("an unclassifiable failure asserted the code was unused — that is not known; got %q", firstAlert(body))
|
||||
}
|
||||
if !strings.Contains(body, "nem tudjuk biztosan") {
|
||||
t.Errorf("the neutral message must say the cause is not known; got %q", firstAlert(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO E — the accusing message requires a REAL attempt ────────────────────────────────────
|
||||
//
|
||||
// §7.2 asks for this as a TEST rather than production logic, and the distinction matters: elapsed
|
||||
// time is the symptom that DIAGNOSED R-224, never a classifier. A production guard on duration would
|
||||
// be a second thing that can be wrong, and §5 forbids it outright.
|
||||
//
|
||||
// So the guard is STRUCTURAL: the typing hint is reachable from exactly ONE class — the one that can
|
||||
// only arise from a 400, which by the agent's contract means the bundle was fetched and age ran. This
|
||||
// asserts that exhaustively, and the clock is injected so the assertion needs no sleeping.
|
||||
//
|
||||
// RED-PROOF: route any instant-failing class (502, a transport error, an unrecognised status) to the
|
||||
// wrong-code message → this FAILS on that row.
|
||||
func TestRecoveryClass_E_OnlyARealRefusalMayMentionTyping(t *testing.T) {
|
||||
instant := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"hub unreachable (502)", refusal(502, "could not be fetched")},
|
||||
{"recovery not configured (503)", refusal(503, "not configured")},
|
||||
{"agent unreachable (transport)", errors.New("dial tcp: connection refused")},
|
||||
{"no bundle (404)", refusal(404, "no sealed bundle")},
|
||||
{"bundle too old (409)", refusal(409, "predates the field")},
|
||||
{"unrecognised (418)", refusal(418, "unanticipated")},
|
||||
}
|
||||
for _, tc := range instant {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
// A clock that NEVER advances: none of these performs an unseal, and the assertion below
|
||||
// must hold without any wall-clock time passing.
|
||||
frozen := time.Date(2026, 8, 6, 4, 0, 0, 0, time.UTC)
|
||||
f.s.SetRecoveryClock(func() time.Time { return frozen })
|
||||
f.rec.failWith = tc.err
|
||||
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
if namesTyping(body) || isBareAccusation(body) {
|
||||
t.Fatalf("a failure that performed NO unseal mentioned typing; got %q", firstAlert(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The positive half: the one class that DID perform an unseal may say it. Without this the test
|
||||
// would pass with the typing message deleted outright.
|
||||
t.Run("a real refusal (400) may mention typing", func(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle")
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
if !namesTyping(body) {
|
||||
t.Fatalf("a genuinely refused code must be told what to check; got %q", firstAlert(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The classifier itself, at the value level — the table the handler switches on. Kept separate from
|
||||
// the handler tests so a mapping change is named directly rather than inferred from Hungarian copy.
|
||||
func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
trusted bool
|
||||
want agentapi.RecoveryFailure
|
||||
}{
|
||||
{"fetch failure", refusal(502, ""), true, agentapi.RecoveryHubUnreachable},
|
||||
{"not configured", refusal(503, ""), true, agentapi.RecoveryHubUnreachable},
|
||||
{"refused, trusted", refusal(400, ""), true, agentapi.RecoveryAskedAndRefused},
|
||||
{"refused, NOT trusted (old agent)", refusal(400, ""), false, agentapi.RecoveryUnknown},
|
||||
{"no bundle", refusal(404, ""), true, agentapi.RecoveryNoBundle},
|
||||
{"bundle too old", refusal(409, ""), true, agentapi.RecoveryBundleTooOld},
|
||||
{"unrecognised status", refusal(418, ""), true, agentapi.RecoveryUnknown},
|
||||
{"transport", errors.New("dial tcp"), true, agentapi.RecoveryAgentUnreachable},
|
||||
{"nil", nil, true, agentapi.RecoveryUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := agentapi.ClassifyRecoveryFailure(tc.err, tc.trusted); got != tc.want {
|
||||
t.Fatalf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The text must be irrelevant: the SAME sentence under two statuses classifies two ways.
|
||||
same := "the recovery code did not open the sealed bundle"
|
||||
if agentapi.ClassifyRecoveryFailure(refusal(400, same), true) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true) {
|
||||
t.Fatal("classification followed the TEXT — it must follow the status")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO G (R-227) — A RESTART MID-UNLOCK IS ANSWERED IN HUNGARIAN ──────────────────────────
|
||||
//
|
||||
// Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and the
|
||||
// customer got traefik's raw English `Bad Gateway`. The state was clean; the page was not.
|
||||
//
|
||||
// The layer that answers is traefik, whose config this repo generates — but traefik v3 serves no
|
||||
// static files, so a branded proxy page would need a new always-up container for every 502 on the
|
||||
// box. What ships is the second sanctioned option: the unlock posts via fetch and answers a gateway
|
||||
// failure in the page. This asserts the handling is PRESENT and says the right thing; with no JS the
|
||||
// plain POST is unchanged and still shows the proxy's error, which the report states plainly.
|
||||
func TestRecoveryClass_G_GatewayErrorIsAnsweredInHungarian(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
body := getRecoveryPage(t, f.s).Body.String()
|
||||
|
||||
// RED-PROOF: delete the fetch handler from recovery.html → this FAILS, and a restart mid-unlock
|
||||
// shows `Bad Gateway` again.
|
||||
if !strings.Contains(body, "unlock-gateway-error") {
|
||||
t.Fatal("R-227 RETURNED: the page carries no handling for a gateway failure")
|
||||
}
|
||||
if !strings.Contains(body, "A gép éppen újraindul") {
|
||||
t.Fatal("the gateway message must say, in Hungarian, that the machine is restarting")
|
||||
}
|
||||
if !strings.Contains(body, "resp.status >= 500") {
|
||||
t.Fatal("a 5xx from the proxy must be caught, not rendered")
|
||||
}
|
||||
// It must claim NOTHING about the code — whether it was used is unknown at that point.
|
||||
if namesTyping(body) && !strings.Contains(body, "Helyreállítási kód (tíz szó)") {
|
||||
t.Fatal("the gateway path must not blame the code")
|
||||
}
|
||||
// Progressive enhancement: the plain form must survive for a JS-less browser.
|
||||
if !strings.Contains(body, `method="POST" action="/recovery/unlock"`) {
|
||||
t.Fatal("the plain POST form must remain for browsers without JS")
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,24 @@ func TestRecoveryGate_H_SupersededPackageIsNamed(t *testing.T) {
|
||||
|
||||
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
|
||||
|
||||
if blamesTyping(body) {
|
||||
t.Fatal("R-222 RETURNED: a code that is RIGHT about a retained earlier package is blamed on the customer's typing")
|
||||
// ⚠ R-226 DELIBERATELY REVERSED HALF OF THIS ASSERTION (2026-08-06), so it is spelled out.
|
||||
//
|
||||
// As first shipped, this test forbade ANY mention of typing here — R-222's guarantee was that a
|
||||
// customer holding the RIGHT code for a retained earlier package must not be told they mistyped.
|
||||
// That was right about the accusation and wrong about the omission: because this branch is tested
|
||||
// BEFORE the wrong-code message, it made the ten-words prompt UNREACHABLE on every box the hub
|
||||
// keeps an earlier package for — which is exactly the box whose customer has just been given a new
|
||||
// code and is most likely to be typing one. Measured 2026-08-05 (CAMPAIGN-11 F1): three genuinely
|
||||
// wrong codes, three real ~1 s unseals, three copies of the earlier-package message.
|
||||
//
|
||||
// The two causes are INDISTINGUISHABLE at the engine, so the message now names BOTH. What stays
|
||||
// forbidden is the bare ACCUSATION — the M1 opener that asserts the code was simply not accepted
|
||||
// and says nothing about the retained package.
|
||||
if strings.Contains(body, "nem fogadtuk el") {
|
||||
t.Fatal("R-222 RETURNED: the bare wrong-code accusation, with no mention of the retained earlier package")
|
||||
}
|
||||
if !blamesTyping(body) {
|
||||
t.Fatal("R-226 RETURNED: a genuine mistype on a re-escrowed box is given no way to discover it was a mistype")
|
||||
}
|
||||
if !strings.Contains(body, "nem töröltük") {
|
||||
t.Errorf("the customer must be told the earlier package is kept; got: %q", firstAlert(body))
|
||||
|
||||
@@ -185,6 +185,47 @@ func (s *Server) SetRecoverySupport(fn func(context.Context) agentapi.SupportSta
|
||||
s.recoverySupportFn = fn
|
||||
}
|
||||
|
||||
// recoveryRefusalTrusted reports whether a 400 from the agent may be read as "the bundle was fetched
|
||||
// and the code was REFUSED" (R-224).
|
||||
//
|
||||
// Only agent >= v0.126.0 splits a failed fetch out to its own status. Before it, 400 covered both,
|
||||
// and reading one as a refusal is precisely how a hub outage became an accusation. Anything other
|
||||
// than a definite yes therefore withholds that reading, and the caller falls to the neutral message.
|
||||
//
|
||||
// ⚠ This gate BLOCKS NOTHING. The unlock is attempted either way — FeatureOffsiteKeyRecovery already
|
||||
// decides that, fail-closed. This only decides whether the customer may be told to check their
|
||||
// typing, and "not sure" means they may not.
|
||||
func (s *Server) recoveryRefusalTrusted(ctx context.Context) bool {
|
||||
if s.recoveryRefusalTrustedFn != nil {
|
||||
return s.recoveryRefusalTrustedFn(ctx)
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRecoveryFailureClass)
|
||||
return state == agentapi.SupportYes
|
||||
}
|
||||
|
||||
// SetRecoveryRefusalTrusted overrides the R-224 version gate (tests). INIT-ONLY.
|
||||
func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) {
|
||||
s.recoveryRefusalTrustedFn = fn
|
||||
}
|
||||
|
||||
// recoveryNow is the clock the unlock path measures itself against. Real time in production; tests
|
||||
// inject so §7.2's guard — the typing message may only follow a REAL unseal — can be asserted
|
||||
// without sleeping. It is an observability seam and a test seam: **it must never become a
|
||||
// classifier.** Time is the symptom that diagnosed R-224; the agent's status is the fact.
|
||||
func (s *Server) recoveryNow() time.Time {
|
||||
if s.recoveryNowFn != nil {
|
||||
return s.recoveryNowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// SetRecoveryClock overrides the unlock clock (tests). INIT-ONLY.
|
||||
func (s *Server) SetRecoveryClock(fn func() time.Time) { s.recoveryNowFn = fn }
|
||||
|
||||
// recoverySuperseded reports the hub's statement that an EARLIER sealed package is kept, and when
|
||||
// (R-222). Both zero on a pre-0.97.0 hub, which keeps the old message — an older hub simply cannot
|
||||
// make the screen claim anything new.
|
||||
@@ -254,12 +295,67 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
// THE SHARED CORE — the same function the command line drives. There is no second recovery
|
||||
// implementation in this codebase (R-193 §8.5).
|
||||
unsealStart := s.recoveryNow()
|
||||
res, rerr := backup.RecoverInstallCore(ctx, s.backupMgr, rec, code, true)
|
||||
unsealTook := s.recoveryNow().Sub(unsealStart)
|
||||
code = "" // cleared here, before any branch below, on success and failure alike
|
||||
if rerr != nil {
|
||||
// The agent's error names the STEP (fetch / unseal / place) and carries no secret. It is not
|
||||
// shown raw: a customer needs to know what to check, not what age's KDF returned.
|
||||
s.logger.Printf("[WARN] [web] recovery: unlock failed: %v", rerr)
|
||||
//
|
||||
// ── R-224 — WHY IT FAILED DECIDES WHAT WE SAY. ─────────────────────────────────────────
|
||||
//
|
||||
// This branch used to be a two-way choice — superseded? M4 : M1 — and BOTH are statements
|
||||
// about the customer's code. `rerr` was never inspected, so a hub that refused, an agent that
|
||||
// was stopped, and a genuinely mistyped code all produced the same accusation.
|
||||
//
|
||||
// Measured live on 2026-08-05 with a CORRECT current code: hub firewalled off → 0.0556 s;
|
||||
// agent stopped → 0.0299 s. A genuine unseal costs ~1.0 s of scrypt, so neither had attempted
|
||||
// one. **The machine accused the customer of something it had not tried.**
|
||||
//
|
||||
// The duration is logged because it is what DIAGNOSED this and it is the cheapest possible
|
||||
// tell for the operator — but it is NEVER the classifier. Time is a symptom; the status is
|
||||
// the fact.
|
||||
class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context()))
|
||||
s.logger.Printf("[WARN] [web] recovery: unlock failed after %s (class=%s): %v", unsealTook.Round(time.Millisecond), class, rerr)
|
||||
switch class {
|
||||
case agentapi.RecoveryHubUnreachable:
|
||||
// The agent answered and could not FETCH the package. The code was NEVER USED. Say that,
|
||||
// name the connection, and say nothing whatever about whether the code is right — we do
|
||||
// not know, and guessing here is the whole defect.
|
||||
s.renderRecovery(w, r, "Most nem sikerült elérni a Felhom központi rendszerét, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen, és próbáld újra néhány perc múlva. Ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryAgentUnreachable:
|
||||
// The machine's own in-house service never answered, so there is no verdict at all. A
|
||||
// different fault from the one above, with a different remedy — and, again, the code was
|
||||
// not used.
|
||||
s.renderRecovery(w, r, "A gép házon belüli szolgáltatása most nem válaszol, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen. A gép magától rendbe jöhet; próbáld újra néhány perc múlva, és ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryNoBundle:
|
||||
// The hub answered, and it holds nothing for this machine. Not the customer's doing, and
|
||||
// not something a different code would fix.
|
||||
s.renderRecovery(w, r, "Ehhez a géphez nem őrzünk lezárt csomagot, ezért nincs mit megnyitni. Ez nem a kódoddal van összefüggésben. Ha korábban készültek házon kívüli mentéseid, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryBundleTooOld:
|
||||
// The code WORKED — the bundle opened. It simply predates the field we need.
|
||||
s.renderRecovery(w, r, "A kódod megnyitotta a csomagot, de az még nem tartalmazza a házon kívüli tárhely kulcsát — régebben készült, mint amikor ezt elkezdtük belerakni, és utólag nem pótolható. A kódoddal semmi baj. Keresd a Felhom ügyfélszolgálatát.", "", nil)
|
||||
return
|
||||
case agentapi.RecoveryAskedAndRefused:
|
||||
// The bundle was fetched and the code did not open it. THIS is the only class from which
|
||||
// the customer may be told to check their typing — see the two messages below.
|
||||
default:
|
||||
// ── THE SAFE DEFAULT (R-224 §7.1). ────────────────────────────────────────────────
|
||||
//
|
||||
// The cause could not be determined: an unrecognised status, an unexpected transport
|
||||
// shape, or an agent older than v0.126.0 whose 400 means "wrong code OR failed fetch"
|
||||
// and cannot be told apart. **We do not know, so we do not guess — and we certainly do
|
||||
// not guess the customer.**
|
||||
//
|
||||
// It claims neither that the code was wrong nor that it went unused; both would be
|
||||
// inventions. This is the branch whose ABSENCE let the defect survive being fixed once.
|
||||
s.renderRecovery(w, r, "A művelet nem fejeződött be, és nem tudjuk biztosan, miért. Semmi nem változott, és a mentéseid érintetlenek. Próbáld újra néhány perc múlva — ha másodszorra sem sikerül, szólj a Felhom ügyfélszolgálatának.", "", nil)
|
||||
return
|
||||
}
|
||||
// ── MESSAGE 4 of 4 — AN EARLIER PACKAGE IS KEPT (R-222) ────────────────────────────────
|
||||
//
|
||||
// The unseal failed against the package the hub CURRENTLY holds. That is the right outcome for
|
||||
@@ -276,12 +372,25 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// opened, because it cannot be: serving a superseded blob is an unbuilt link (R-199's
|
||||
// inventory), and a conditional promise that turns out false on this screen is worse than
|
||||
// saying less (the R-202 lesson).
|
||||
//
|
||||
// ── R-226 — AND IT MUST NAME THE MISTYPE TOO. ─────────────────────────────────────────
|
||||
//
|
||||
// As shipped, this message spoke only of an earlier package and sent the customer to support.
|
||||
// But it is tested BEFORE the typing message, so on every box the hub keeps an earlier package
|
||||
// for — precisely the boxes whose customer has just been handed a NEW recovery code and is
|
||||
// most likely to be typing one — a genuine mistype produced this text and the ten-words prompt
|
||||
// became unreachable. Measured on 2026-08-05 (CAMPAIGN-11 F1): three deliberately wrong codes,
|
||||
// three real ~1 s unseals, three copies of this message.
|
||||
//
|
||||
// The two are INDISTINGUISHABLE at the engine — both fail closed against the current package —
|
||||
// so the honest message names both and does not pretend to know which. Do not try to tell them
|
||||
// apart; there is nothing to tell them apart with.
|
||||
if present, at := s.recoverySuperseded(); present {
|
||||
when := ""
|
||||
if at != "" {
|
||||
when = " (" + at + ")"
|
||||
}
|
||||
s.renderRecovery(w, r, "Ez a kód nem nyitja meg azt a csomagot, amit most őrzünk ehhez a géphez. Ha egy korábbi kódot adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot"+when+" nem töröltük — megőrizzük. Megnyitni viszont innen egyelőre nem lehet, ezért ha a régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem változott.", "", nil)
|
||||
s.renderRecovery(w, r, "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Két oka lehet, és innen nem tudjuk megkülönböztetni őket. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be — a kis- és nagybetűk nem számítanak. Vagy egy korábbi kódot adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot"+when+" nem töröltük — megőrizzük, megnyitni viszont innen egyelőre nem lehet. Ha újrapróbálod és úgy sem megy, és a régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem változott.", "", nil)
|
||||
return
|
||||
}
|
||||
// ── MESSAGE 1 of 4 — THE CODE DID NOT OPEN IT. The ONLY one that mentions typing. ──────────
|
||||
|
||||
@@ -3,7 +3,6 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -38,16 +37,26 @@ type fakeRecoverer struct {
|
||||
pw string
|
||||
sha string
|
||||
fail bool
|
||||
codes []string
|
||||
// failWith overrides `fail` with an EXACT error value, so a test can drive one specific
|
||||
// R-224 class (a 502 fetch failure, a transport error, an unrecognised status).
|
||||
failWith error
|
||||
codes []string
|
||||
}
|
||||
|
||||
func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
f.codes = append(f.codes, code)
|
||||
f.mu.Unlock()
|
||||
if f.failWith != nil {
|
||||
return "", "", f.failWith
|
||||
}
|
||||
if f.fail {
|
||||
// The shape the agent returns: names the STEP, never the code.
|
||||
return "", "", fmt.Errorf("unseal failed: age: incorrect passphrase")
|
||||
// R-224: the shape agent >= v0.126.0 returns for a code that was TRIED AND REFUSED — HTTP 400,
|
||||
// meaning the bundle was fetched and `age -d` rejected it. It used to be a bare error here,
|
||||
// which is the shape of a failure we could NOT classify; under the R-224 rule that now renders
|
||||
// the neutral message, and rightly so. Saying "wrong code" in a test requires saying it the way
|
||||
// the agent says it.
|
||||
return "", "", &agentapi.RecoveryRefusal{Status: 400, Reason: "the recovery code did not open the sealed bundle — nothing was written"}
|
||||
}
|
||||
return f.pw, f.sha, nil
|
||||
}
|
||||
@@ -133,6 +142,9 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
|
||||
// the protection working, and exactly not what those tests are about. The refusal has its own
|
||||
// tests in recovery_gate_test.go, each overriding this.
|
||||
s.SetRecoverySupport(func(context.Context) agentapi.SupportState { return agentapi.SupportYes })
|
||||
// R-224: the fixture's agent is a current one, so a 400 may be read as a genuine refusal. Tests
|
||||
// that need the OLD-agent behaviour override this explicitly.
|
||||
s.SetRecoveryRefusalTrusted(func(context.Context) bool { return true })
|
||||
return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@ type Server struct {
|
||||
// recoverySupportFn overrides the R-216 agent-capability verdict for the unlock path (tests).
|
||||
// nil → the real gate over netFeatures. See recoverySupport: Unknown means CANNOT ASK here.
|
||||
recoverySupportFn func(context.Context) agentapi.SupportState
|
||||
// recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a
|
||||
// genuine refusal (tests). nil → the agent-version path.
|
||||
recoveryRefusalTrustedFn func(context.Context) bool
|
||||
// recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and
|
||||
// tests only — never a classifier.
|
||||
recoveryNowFn func() time.Time
|
||||
// recoveryTierUp brings the off-site tier up between placing a recovered key and reading the
|
||||
// repository (R-219). Wired from main.go to the apply-bridge's Reconcile; nil → skipped, and the
|
||||
// listing branch then reports what is pending rather than claiming a failure.
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<div class="stat-label">Utolsó távoli mentés{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgoStr .Offbox.LastRun}}</span>{{end}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.15rem">{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép</div>
|
||||
<div class="stat-value" style="font-size:1.15rem">{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Tároló méret · {{if .Offbox.StatsKnown}}{{.Offbox.SnapshotCount}} pillanatkép{{else}}a pillanatképek száma még ismeretlen{{end}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.05rem">{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}</div>
|
||||
@@ -58,12 +58,31 @@
|
||||
</div>
|
||||
</div>
|
||||
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}}
|
||||
<!-- R-225: the BAR renders only when the fill is known. A 0%-wide bar over an unread store is a
|
||||
picture of emptiness, and a picture is a claim. -->
|
||||
<!-- SLICE 4: soft-quota usage bar (shared model; quota_gb from the hub descriptor). -->
|
||||
<div id="offbox-quota-bar" style="max-width:560px;margin:.5rem 0">
|
||||
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)</div>
|
||||
<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
|
||||
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: {{if .Offbox.StatsKnown}}{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%){{else}}még nem tudjuk, mennyi van a tárolóban — legfeljebb {{.Offbox.QuotaGB}} GB{{end}}</div>
|
||||
{{if .Offbox.StatsKnown}}<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
|
||||
<div style="height:8px;border-radius:4px;width:{{.OffboxQuotaPct}}%;background:{{if ge .OffboxQuotaPct 100}}var(--crit,#e5484d){{else if ge .OffboxQuotaPct 80}}var(--warn,#f5a524){{else}}var(--ok,#30a46c){{end}}"></div>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* R-228 — THE SET-ASIDE HISTORY IS SAID OUT LOUD.
|
||||
The customer chose "I do not want the old data", was told it would be KEPT and not deleted,
|
||||
and then it vanished from every screen: the box recorded exactly where it went
|
||||
(OrphanedRenamedTo) and showed that to nobody. Measured 2026-08-05 (CAMPAIGN-11 F7) —
|
||||
12 535 KB at a path with zero references in any template or handler.
|
||||
|
||||
NOTE — IT STATES TWO FACTS AND STOPS. It does NOT promise the history can be reopened, because it
|
||||
cannot be: serving a superseded package is an unbuilt link (R-199's inventory). A conditional
|
||||
promise that turns out false is worse here than saying less — the R-202 lesson. */}}
|
||||
{{if .Offbox.OrphanedRenamedTo}}
|
||||
<div class="alert alert-info" style="margin-top:.75rem">
|
||||
<p><strong>A korábbi mentéseid félre vannak téve — nem töröltük őket.</strong></p>
|
||||
<p class="form-hint">Amikor új mentési kulcsot kapott a géped, a régebbi előzményt átmozgattuk
|
||||
a távoli tárhelyen, és ott is maradt. <strong>Megnyitni innen egyelőre nem lehet</strong>, és
|
||||
ez nem a kódodon múlik. Ha szükséged van rá, keresd a Felhom ügyfélszolgálatát.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
|
||||
|
||||
@@ -89,7 +89,9 @@
|
||||
semmi nem változik.</strong> A visszaállítást utána, alkalmazásonként külön választhatod.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="/recovery/unlock" autocomplete="off">
|
||||
<div id="unlock-gateway-error" class="alert alert-error" style="display:none" role="alert"></div>
|
||||
|
||||
<form id="unlock-form" method="POST" action="/recovery/unlock" autocomplete="off">
|
||||
{{.CSRFField}}
|
||||
<label for="recovery_code">Helyreállítási kód (tíz szó)</label>
|
||||
<input type="password" id="recovery_code" name="recovery_code"
|
||||
@@ -104,6 +106,57 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* R-227 — A RESTART MID-UNLOCK MUST NOT SHOW A RAW ENGLISH GATEWAY ERROR.
|
||||
|
||||
Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and
|
||||
the customer got traefik's `Bad Gateway` — a raw upstream error, in English, naming no reason
|
||||
and saying nothing about whether the key was installed. The state was clean; only the page
|
||||
was not. It breaches I3 (every refusal names a reason a person can act on, in Hungarian, with
|
||||
no raw error).
|
||||
|
||||
WHICH LAYER ANSWERS: traefik, and its config IS generated by this repo
|
||||
(internal/infra/templates/traefik*.tmpl). A fully branded proxy error page is therefore
|
||||
possible here — but traefik v3 serves no static files itself, so it would need a new
|
||||
always-up container purely to hold an error page, for every 502 on the box. That is out of
|
||||
proportion to this finding and is scoped in the report rather than built.
|
||||
|
||||
What ships instead is the second sanctioned option: the unlock posts via fetch, so a gateway
|
||||
error or a dropped connection is caught in the page and answered in Hungarian, without
|
||||
leaving it. PROGRESSIVE ENHANCEMENT — with no JS the plain POST is unchanged, and that path
|
||||
still shows the proxy's own error. Said plainly rather than implied. */}}
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('unlock-form');
|
||||
var box = document.getElementById('unlock-gateway-error');
|
||||
if (!form || !box || !window.fetch) { return; }
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
box.style.display = 'none';
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Feloldás folyamatban…'; }
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(form),
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow'
|
||||
}).then(function (resp) {
|
||||
if (resp.status >= 500) { throw new Error('gateway'); }
|
||||
return resp.text().then(function (html) {
|
||||
document.open(); document.write(html); document.close();
|
||||
});
|
||||
}).catch(function () {
|
||||
// A 5xx from the proxy, or no response at all: the machine is very likely restarting.
|
||||
// NOTHING is claimed about the code — we do not know whether it was used.
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Mentések feloldása'; }
|
||||
box.textContent = 'A gép éppen újraindul, ezért most nem tudtuk befejezni a műveletet. '
|
||||
+ 'Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz, '
|
||||
+ 'úgyhogy tartsd kéznél.';
|
||||
box.style.display = '';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<p class="form-hint">
|
||||
A „Most nem” csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől
|
||||
megmaradnak, és ez az oldal a <strong>Biztonsági mentés → Távoli mentés</strong> oldalról
|
||||
@@ -118,7 +171,7 @@
|
||||
<p>Ha megerősíted:</p>
|
||||
<ul>
|
||||
<li>a korábbi mentéseket <strong>félretesszük — nem töröljük</strong>;</li>
|
||||
<li>a helyreállítási kód nélkül <strong>többé nem lesznek megnyithatók</strong>;</li>
|
||||
<li>a félretett mentések <strong>innen többé nem nyithatók meg</strong> — sem kóddal, sem anélkül;</li>
|
||||
<li>a gép <strong>új, üres mentési tárolót kezd</strong>, és mostantól oda ment;</li>
|
||||
<li>ez az oldal <strong>többé nem jelenik meg</strong>.</li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user