package server import ( "encoding/json" "net/http" "strconv" "strings" "testing" "agent-system-backend/internal/store" ) func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() admin := bootAdmin(t, h, st) pending, err := st.CreatePendingAccount("admin", "recovery-user", store.AccountProfile{}) if err != nil { t.Fatal(err) } activation, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) if err != nil { t.Fatal(err) } setup := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "") if setup.Code != http.StatusOK || !strings.Contains(setup.Body.String(), `"passwordChangeMode":"grant_setup"`) { t.Fatalf("activation redeem: %d %s", setup.Code, setup.Body.String()) } if rec := do(h, "POST", "/api/auth/password", `{"next":"alice-initial-password"}`, cookieOf(setup)); rec.Code != http.StatusOK { t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) } ownerLogin := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-initial-password"}`, "") if ownerLogin.Code != http.StatusOK { t.Fatalf("trusted owner login: %d %s", ownerLogin.Code, ownerLogin.Body.String()) } ownerCookie := cookieOf(ownerLogin) account, err := st.GetAccount(pending.ID) if err != nil { t.Fatal(err) } proposalResponse := do(h, "POST", "/api/admin/users/"+pending.ID+"/recovery-proposals", `{"expectedStateVersion":`+jsonNumber(account.StateVersion)+`,"idempotencyKey":"http-request-1"}`, admin) if proposalResponse.Code != http.StatusOK || strings.Contains(proposalResponse.Body.String(), "grant") { t.Fatalf("administrator proposal leaked grant: %d %s", proposalResponse.Code, proposalResponse.Body.String()) } var proposal store.RecoveryProposal if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil || proposal.ID == "" { t.Fatalf("proposal metadata: %+v %v", proposal, err) } if got := do(h, "GET", "/api/admin/users/"+pending.ID+"/recovery-proposals/"+proposal.ID, "", admin); got.Code != http.StatusOK || strings.Contains(got.Body.String(), "grant") { t.Fatalf("administrator status leaked grant: %d %s", got.Code, got.Body.String()) } confirmed := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie) if confirmed.Code != http.StatusOK || confirmed.Header().Get("Cache-Control") != "no-store" { t.Fatalf("owner confirm: %d %s", confirmed.Code, confirmed.Body.String()) } var confirmation struct { Proposal store.RecoveryProposal `json:"proposal"` Grant string `json:"grant"` } if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" { t.Fatalf("confirmation secret response: %v", err) } if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) { t.Fatalf("duplicate confirmation replayed grant: %d %s", duplicate.Code, duplicate.Body.String()) } if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) { t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String()) } rotated := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie) if rotated.Code != http.StatusOK { t.Fatalf("owner rotate: %d %s", rotated.Code, rotated.Body.String()) } var rotation struct { Grant string `json:"grant"` } if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant { t.Fatalf("rotation grant: %v", err) } if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) { t.Fatalf("duplicate rotation replayed grant: %d %s", duplicate.Code, duplicate.Body.String()) } if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK { t.Fatal("old recovery grant remained usable") } redeemed := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+rotation.Grant+`"}`, "") if redeemed.Code != http.StatusOK { t.Fatalf("recovery redeem: %d %s", redeemed.Code, redeemed.Body.String()) } if done := do(h, "POST", "/api/auth/password", `{"next":"alice-recovered-password"}`, cookieOf(redeemed)); done.Code != http.StatusOK { t.Fatalf("recovery finish: %d %s", done.Code, done.Body.String()) } if login := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-recovered-password"}`, ""); login.Code != http.StatusOK { t.Fatalf("relogin: %d %s", login.Code, login.Body.String()) } } func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } func TestLegacyAdministratorLoginKeyIssuanceEntrancesAreClosed(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() admin := bootAdmin(t, h, st) hash, err := store.HashPassword("old-victim-pass") if err != nil { t.Fatal(err) } if err := st.CreateAccount("victim", "", hash, false); err != nil { t.Fatal(err) } if err := st.ClearMustChangePassword("victim"); err != nil { t.Fatal(err) } for _, route := range []struct{ path, body string }{ {"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`}, {"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`}, } { rec := do(h, "POST", route.path, route.body, admin) if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "RECOVERY_CONTRACT_REPLACED") { t.Fatalf("legacy route remained active: %s %d %s", route.path, rec.Code, rec.Body.String()) } } if _, ok := st.VerifyLogin("victim", "old-victim-pass"); !ok { t.Fatal("closed routes changed existing password") } if keys, err := st.ListLoginKeys("victim"); err != nil || len(keys) != 0 { t.Fatalf("closed routes issued a grant: %+v %v", keys, err) } } func TestLegacyGrantAndRestrictedSessionCannotSkipCurrentPassword(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() hash, err := store.HashPassword("old-victim-pass") if err != nil { t.Fatal(err) } if err := st.CreateAccount("victim", "", hash, false); err != nil { t.Fatal(err) } grant, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) if err != nil { t.Fatal(err) } if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+grant+`"}`, ""); rec.Code == http.StatusOK { t.Fatal("legacy administrator grant created a restricted session") } if err := st.SetUserPassword("victim", hash); err != nil { t.Fatal(err) } login := do(h, "POST", "/api/auth/login", `{"owner":"victim","password":"old-victim-pass"}`, "") if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"current_required"`) { t.Fatalf("legacy forced login mode: %d %s", login.Code, login.Body.String()) } cookie := cookieOf(login) if rec := do(h, "POST", "/api/auth/password", `{"next":"victim-new-pass"}`, cookie); rec.Code == http.StatusOK { t.Fatal("forced password session skipped current password") } if rec := do(h, "POST", "/api/auth/password", `{"current":"old-victim-pass","next":"victim-new-pass"}`, cookie); rec.Code != http.StatusOK { t.Fatalf("legacy forced change with current failed: %d %s", rec.Code, rec.Body.String()) } if _, ok := st.VerifyLogin("victim", "victim-new-pass"); !ok { t.Fatal("current-required password change did not complete") } }