Files
Agent-System-Backend/internal/server/recovery_contract_test.go
T

168 lines
7.9 KiB
Go

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")
}
}