// ui_host_delete_test.go — covers the admin-band danger-zone host // delete handler: hostname-confirm gate, RBAC, FK cascade, redirect, // audit. package http import ( "context" "errors" stdhttp "net/http" "net/url" "strings" "testing" "time" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // loginAsRole mints a fresh user of the given role and returns a // session cookie. Local twin to keep the RBAC test self-contained // without leaking yet another helper into the shared test package. func loginAsRole(t *testing.T, st *store.Store, role store.Role) *stdhttp.Cookie { t.Helper() ctx := context.Background() uid := ulid.Make().String() hash, _ := auth.HashPassword("very-long-test-password") if err := st.CreateUser(ctx, store.User{ ID: uid, Username: string(role) + "-" + uid[:6], PasswordHash: hash, Role: role, CreatedAt: time.Now().UTC(), }); err != nil { t.Fatalf("create user: %v", err) } tok, _ := auth.NewToken() if err := st.CreateSession(ctx, store.Session{ UserID: uid, CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(time.Hour).UTC(), }, auth.HashToken(tok)); err != nil { t.Fatalf("create session: %v", err) } return &stdhttp.Cookie{Name: sessionCookieName, Value: tok} } // TestHostDeleteWrongHostnameRejected: typing a different name must // not delete the host. Handler returns 400 and the row is intact. func TestHostDeleteWrongHostnameRejected(t *testing.T) { t.Parallel() _, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, nil, st, "del-wrong-host") cookie := loginAsAdmin(t, st) form := url.Values{"confirm_hostname": {"NOT-THE-NAME"}} req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/delete", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusBadRequest { t.Fatalf("status: got %d, want 400", res.StatusCode) } if _, err := st.GetHost(context.Background(), hostID); err != nil { t.Fatalf("host should still exist; got %v", err) } } // TestHostDeleteRequiresAdmin: a viewer or operator gets 403 — host // stays intact. func TestHostDeleteRequiresAdmin(t *testing.T) { t.Parallel() _, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, nil, st, "del-rbac-host") for _, role := range []store.Role{store.RoleViewer, store.RoleOperator} { role := role t.Run(string(role), func(t *testing.T) { cookie := loginAsRole(t, st, role) form := url.Values{"confirm_hostname": {"del-rbac-host"}} req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/delete", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusForbidden { t.Fatalf("status: got %d, want 403", res.StatusCode) } if _, err := st.GetHost(context.Background(), hostID); err != nil { t.Fatalf("host should still exist; got %v", err) } }) } } // TestHostDeleteHappyPathCascadesAndAudits: matching hostname removes // the row, FK cascade wipes the seeded job, and an audit row lands. func TestHostDeleteHappyPathCascadesAndAudits(t *testing.T) { t.Parallel() _, ts, st := rawTestServerWithUI(t) hostID, _ := enrolHostForUI(t, nil, st, "del-ok-host") // Seed one dependent row to prove the cascade fires through HTTP. if err := st.CreateJob(context.Background(), store.Job{ ID: ulid.Make().String(), HostID: hostID, Kind: "backup", ActorKind: "system", CreatedAt: time.Now().UTC(), }); err != nil { t.Fatalf("seed job: %v", err) } cookie := loginAsAdmin(t, st) form := url.Values{"confirm_hostname": {"del-ok-host"}} req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/delete", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) // Don't follow the redirect so we can assert it. cli := &stdhttp.Client{ CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { return stdhttp.ErrUseLastResponse }, } res, err := cli.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusSeeOther { t.Fatalf("status: got %d, want 303", res.StatusCode) } if loc := res.Header.Get("Location"); loc != "/" { t.Errorf("Location: got %q, want /", loc) } // Host gone. if _, err := st.GetHost(context.Background(), hostID); !errors.Is(err, store.ErrNotFound) { t.Errorf("GetHost after delete: want ErrNotFound, got %v", err) } // Cascade fired (job row gone). var n int if err := st.DB().QueryRow(`SELECT COUNT(*) FROM jobs WHERE host_id = ?`, hostID).Scan(&n); err != nil { t.Fatalf("count jobs: %v", err) } if n != 0 { t.Errorf("cascade left %d job rows", n) } // Audit row landed. var audN int if err := st.DB().QueryRow( `SELECT COUNT(*) FROM audit_log WHERE action = 'host.deleted' AND target_id = ?`, hostID).Scan(&audN); err != nil { t.Fatalf("count audit: %v", err) } if audN != 1 { t.Errorf("audit rows: got %d, want 1", audN) } }