hub v0.104.0: the guest network gets a reader (R-319), and the hub half of the naming (R-295)
gates / gates (push) Successful in 14s
gates / gates (push) Successful in 14s
Four paper debts and one fact given a reader. Hub-only — nothing to bake. A4 — the entry about "the tester's machine" named a risk correctly and labelled it in a way that invited deleting it. Established from the hub's own store: `peti-felhom` is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47 this morning. The prompt's premise conflated the two; the register now says which is which. A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out of STATUS's "Waiting on you", which is now empty. A3 — day0-install §C.1 said pushing the installer publishes it. It has not since R-110. Corrected, with the two manifest pins named and an outside-verification command; the one copy that repeated it (a dated audit, true when written) carries a superseded note. A5 — standing rule 5: evidence comes off the machine at the end of the phase that produced it, before any revert. Earned twice in three days on the same box at the same point (R-320). Four homes, plus what to do when it is already gone. R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll` mail kind so the mail names the page a REBUILT box actually shows („A szerver beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach. Naming only; the acceptance pin proves the secret is untouched. R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never drawn as healthy — three absences, three sentences. No alarm, deliberately. Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190. B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now. Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled` reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed. Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
This commit is contained in:
@@ -29,8 +29,21 @@ type EmailKind string
|
||||
|
||||
const (
|
||||
EmailClaim EmailKind = "claim" // first setup: "Elindult a Felhom szervered"
|
||||
EmailReset EmailKind = "reset" // forgotten password
|
||||
EmailReset EmailKind = "reset" // forgotten password — the box HAS a password
|
||||
EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code)
|
||||
// EmailReenroll — the box was wiped and re-enrolled, so the fresh controller has NO password
|
||||
// while the hub-side claim is still set (ReissueForReenroll).
|
||||
//
|
||||
// R-295 (hub half, 2026-08-13): THIS EXISTS BECAUSE THE MAIL MUST NAME THE PAGE THE MACHINE IS
|
||||
// ACTUALLY SHOWING. Both situations deliver the same secret and it keeps the same name — the
|
||||
// three-word „Beállító kód" — but they do NOT show the same screen, and only the hub can tell
|
||||
// them apart, because it is the hub that chose which call site fired. A rebuilt box renders
|
||||
// „A szerver beállítása" (controller `web/claim.go:279`: `reset := s.authEnabled()`, and a fresh
|
||||
// controller has no password), and it serves no login page — so it has no „Elfelejtett jelszó"
|
||||
// link at all. Sending a re-enrolled customer to that page names a route that is not on their
|
||||
// screen. Splitting the KIND rather than the NAME is what the ruling asks for: one secret in two
|
||||
// situations keeps its name, and the sentence around it changes.
|
||||
EmailReenroll EmailKind = "reenroll"
|
||||
)
|
||||
|
||||
// Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or
|
||||
@@ -178,7 +191,8 @@ func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued
|
||||
if cs == nil || !cs.Claimed() {
|
||||
return 0, false, nil // unclaimed → first-provision path; nothing to re-issue
|
||||
}
|
||||
gen, err = e.rotateAndSend(cc, EmailReset)
|
||||
// EmailReenroll, not EmailReset: same secret, same name, different screen — see the constant.
|
||||
gen, err = e.rotateAndSend(cc, EmailReenroll)
|
||||
if err != nil {
|
||||
return gen, true, err // reissued=true so the caller records the attempt even on email failure
|
||||
}
|
||||
|
||||
@@ -166,8 +166,14 @@ func TestReissueForReenroll(t *testing.T) {
|
||||
if !cs.Claimed() {
|
||||
t.Fatal("re-issue must NEVER un-claim (reset rides rotation)")
|
||||
}
|
||||
if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reset:") {
|
||||
t.Fatalf("claimed re-enroll must send exactly one RESET email, got %v", m.sends)
|
||||
// R-295 hub half (2026-08-13): this used to assert "reset:". The KIND was split — same
|
||||
// secret, same name („Beállító kód"), different SENTENCE — because a rebuilt box has no
|
||||
// password, so it shows „A szerver beállítása" and serves no login page, and the reset mail
|
||||
// sent the customer to an „Elfelejtett jelszó" page that is not on their screen. The
|
||||
// assertion is deliberately kept STRICT rather than loosened to "either kind": routing a
|
||||
// re-enrolment back down the reset copy is exactly the regression worth failing on.
|
||||
if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reenroll:") {
|
||||
t.Fatalf("claimed re-enroll must send exactly one REENROLL email, got %v", m.sends)
|
||||
}
|
||||
})
|
||||
t.Run("unclaimed is a no-op (first-provision path)", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package claim
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ── R-295, HUB HALF — ONE NAME PER SECRET, AND THE MAIL NAMES THE PAGE THE MACHINE SHOWS ────────
|
||||
//
|
||||
// Two different secrets were both called „Visszaállító kód": the THREE-word code that gives a person
|
||||
// control of the dashboard, and the TEN-word code that opens the sealed off-site backups. They are
|
||||
// near-homographs of each other and of „Helyreállítási kód", and the collision cost a real code. The
|
||||
// box side shipped on 2026-08-10; the hub side was dropped twice and was the last place the retired
|
||||
// name survived — in the mails, which is the one surface a customer reads BEFORE they see any screen.
|
||||
//
|
||||
// The ruling these tests pin:
|
||||
// - the three-word dashboard code is „Beállító kód" everywhere;
|
||||
// - the ten-word escrow code is „Helyreállítási kód";
|
||||
// - „Visszaállító kód" is RETIRED;
|
||||
// - one secret in two situations keeps its NAME and changes its SENTENCE;
|
||||
// - the mail names the page the machine is ACTUALLY SHOWING.
|
||||
//
|
||||
// THIS IS NAMING, NOT FUNCTION — TestReenrollSplit_ChangesTheMailNotTheSecret is the pin that says
|
||||
// so. On the box side the companion pin is the controller's
|
||||
// TestResetCode_StillAcceptedOnTheSetupPage (claim_code_naming_test.go).
|
||||
|
||||
const retiredName = "Visszaállító kód"
|
||||
|
||||
// The three code-bearing mails all name the secret „Beállító kód", and none of them carries the
|
||||
// retired name. The escrow code's name must not appear in a dashboard-code mail either: naming both
|
||||
// secrets in one message is how a customer comes to believe they are the same thing.
|
||||
func TestFormatClaimEmail_OneNamePerSecret(t *testing.T) {
|
||||
for _, kind := range []EmailKind{EmailClaim, EmailReset, EmailReenroll} {
|
||||
subject, body := notify.FormatClaimEmail(string(kind), "c1", "example.hu", "alma-korte-szilva")
|
||||
whole := subject + "\n" + body
|
||||
|
||||
if !strings.Contains(body, "Beállító kód: alma-korte-szilva") {
|
||||
t.Errorf("%s: the secret must be labelled „Beállító kód”; body was:\n%s", kind, body)
|
||||
}
|
||||
if strings.Contains(whole, retiredName) {
|
||||
t.Errorf("%s: the retired name „%s” is back (subject or body)", kind, retiredName)
|
||||
}
|
||||
if strings.Contains(whole, "Helyreállítási kód") {
|
||||
t.Errorf("%s: names the ESCROW code in a dashboard-code mail — that is the collision", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The two situations that deliver the SAME secret must not name the SAME page, because the machine
|
||||
// does not show the same page. A box that still has a password shows a login screen carrying an
|
||||
// „Elfelejtett jelszó" link; a REBUILT box has no password, renders „A szerver beállítása" and serves
|
||||
// no login page at all (controller web/claim.go:279 — `reset := s.authEnabled()`).
|
||||
//
|
||||
// Sending a re-enrolled customer to „Elfelejtett jelszó" names a route that is not on their screen.
|
||||
// That was the live defect, and this is the test that would have caught it.
|
||||
func TestFormatClaimEmail_NamesThePageTheMachineShows(t *testing.T) {
|
||||
_, resetBody := notify.FormatClaimEmail(string(EmailReset), "c1", "example.hu", "a-b-c")
|
||||
if !strings.Contains(resetBody, `"Elfelejtett jelszó"`) {
|
||||
t.Errorf("the forgot-password mail should name the page that IS on that customer's screen:\n%s", resetBody)
|
||||
}
|
||||
|
||||
_, reBody := notify.FormatClaimEmail(string(EmailReenroll), "c1", "example.hu", "a-b-c")
|
||||
if strings.Contains(reBody, "Elfelejtett jelszó") {
|
||||
t.Errorf("a REBUILT box serves no login page, so it has no „Elfelejtett jelszó” link:\n%s", reBody)
|
||||
}
|
||||
if !strings.Contains(reBody, `"A szerver beállítása"`) {
|
||||
t.Errorf("the re-enrol mail must name the page a rebuilt box actually shows:\n%s", reBody)
|
||||
}
|
||||
}
|
||||
|
||||
// The re-enrol mail must promise nothing about the customer's apps or backups. A clean-slate
|
||||
// reinstall is exactly the situation in which such a reassurance could be false, and this project has
|
||||
// spent four register rows (R-294, R-299, R-302, R-311) removing promises it could not see were still
|
||||
// true. Guarding the CLAIM rather than one phrasing of it: any sentence that says the backups are
|
||||
// unaffected would have to say so with one of these stems.
|
||||
func TestFormatClaimEmail_ReenrollPromisesNothingAboutTheData(t *testing.T) {
|
||||
_, body := notify.FormatClaimEmail(string(EmailReenroll), "c1", "example.hu", "a-b-c")
|
||||
for _, claimWord := range []string{"mentés", "biztonsági", "alkalmazás", "adataid", "visszaállíthat", "visszaszerezhet"} {
|
||||
if strings.Contains(body, claimWord) {
|
||||
t.Errorf("the re-enrol mail must not talk about data or backups (found %q):\n%s", claimWord, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unrecognised kind falls through to the first-setup mail. That default is SAFE (a rebuilt box
|
||||
// really is in a setup state) and it is deliberately asserted, because it is the failure mode of
|
||||
// adding a kind on the engine side and forgetting the template: nothing panics, nothing errors, and
|
||||
// the wrong-but-harmless mail goes out. Pinning it means a future reader knows it was chosen.
|
||||
func TestFormatClaimEmail_UnknownKindFallsBackToSetup(t *testing.T) {
|
||||
subject, body := notify.FormatClaimEmail("no-such-kind", "c1", "example.hu", "a-b-c")
|
||||
if !strings.Contains(subject, "Elindult a Felhom szervered") {
|
||||
t.Errorf("unknown kind should fall back to the first-setup mail, got subject %q", subject)
|
||||
}
|
||||
if strings.Contains(subject+body, retiredName) {
|
||||
t.Errorf("the fallback carries the retired name")
|
||||
}
|
||||
}
|
||||
|
||||
// THE ACCEPTANCE PIN. Splitting the mail KIND must not touch the secret: ReissueForReenroll still
|
||||
// rotates the generation, still stores a bcrypt hash that verifies the emailed code, and still stores
|
||||
// no plaintext — byte-for-byte the same custody as the reset path it was split out of. A rename that
|
||||
// quietly broke acceptance would be far worse than the collision it fixes.
|
||||
func TestReenrollSplit_ChangesTheMailNotTheSecret(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
cc := cust()
|
||||
if _, err := e.EnsureIssued(cc); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
if err := e.MarkClaimed(cc); err != nil {
|
||||
t.Fatalf("MarkClaimed: %v", err)
|
||||
}
|
||||
before, err := st.GetClaim(cc.CustomerID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClaim: %v", err)
|
||||
}
|
||||
|
||||
gen, reissued, err := e.ReissueForReenroll(cc)
|
||||
if err != nil || !reissued {
|
||||
t.Fatalf("ReissueForReenroll: gen=%d reissued=%v err=%v", gen, reissued, err)
|
||||
}
|
||||
|
||||
// It is the re-enrol mail, not the reset mail — the whole point of the split.
|
||||
last := m.sends[len(m.sends)-1]
|
||||
if !strings.HasPrefix(last, string(EmailReenroll)+":") {
|
||||
t.Errorf("a re-enrolled box must get the reenroll mail, got %q", last)
|
||||
}
|
||||
|
||||
// …and the secret behaves exactly as before: generation advanced, the stored hash verifies the
|
||||
// newly emailed code, the previous code no longer verifies, and no plaintext is persisted.
|
||||
after, err := st.GetClaim(cc.CustomerID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClaim after: %v", err)
|
||||
}
|
||||
if after.Generation != before.Generation+1 {
|
||||
t.Errorf("generation %d → %d, want +1", before.Generation, after.Generation)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(after.CodeHash), []byte(m.lastCode)) != nil {
|
||||
t.Error("the stored hash does not verify the emailed code — acceptance moved")
|
||||
}
|
||||
if strings.Contains(after.CodeHash, m.lastCode) {
|
||||
t.Error("the stored hash contains the plaintext code")
|
||||
}
|
||||
}
|
||||
|
||||
// RequestReset — a customer who still has a password — must keep sending the RESET mail. The split
|
||||
// must not have swept the genuine forgot-password path along with it: that customer IS looking at a
|
||||
// login page, and „Elfelejtett jelszó" is the right thing to name for them.
|
||||
func TestRequestReset_StillSendsTheResetMail(t *testing.T) {
|
||||
e, _, m := newTestEngine(t)
|
||||
cc := cust()
|
||||
if _, err := e.EnsureIssued(cc); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
if err := e.MarkClaimed(cc); err != nil {
|
||||
t.Fatalf("MarkClaimed: %v", err)
|
||||
}
|
||||
if err := e.RequestReset(cc); err != nil {
|
||||
t.Fatalf("RequestReset: %v", err)
|
||||
}
|
||||
last := m.sends[len(m.sends)-1]
|
||||
if !strings.HasPrefix(last, string(EmailReset)+":") {
|
||||
t.Errorf("a forgot-password request must still send the reset mail, got %q", last)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user