Files
cairnobs/api/logretention/handler_test.go
T
jcoffey-dev 5ff2e5bb60 Scope log retention deletion and floors to (host, service), not host alone
logs rows carry a real per-record `service` (nginx, smtp, ufw, ...) --
already true of the schema (storage/migrations/0001) and wire protocol,
not something this feature invents. Both the deletion picker and the
retention floor now operate on (host, service) pairs instead of whole
hosts, so an operator can delete just one noisy log type from an agent
without touching everything else it ships, and can protect one service
(e.g. keep smtp a year) longer than the rest of that host's default.

api/agents.ConfigOverride gains ServiceLogRetentionDays (map[string]int),
owner-only to change like LogRetentionDays -- a service listed there
overrides the host's LogRetentionDays default for that service only.
Agent config page gets a matching "Per-service log retention overrides"
add/remove list next to the existing host-level field.

api/logretention: Store's count/delete now take []HostService and build
a ClickHouse tuple IN ((?,?),...) over (host, service); AgentRetentionStore.
FloorsByHost returns each host's default plus its per-service map, with
HostFloor.Effective(service) resolving which one applies. preview/delete
moved from GET/DELETE-with-query-params to POST-with-JSON-body (a list of
targets needs a real body, not a repeated compound query param), and
partitionTargets checks the floor per target so one protected service
never blocks deleting a different, unprotected one in the same request.

Settings' Log retention section is a two-level picker now: each host
row (with a "select all services" checkbox and its default floor badge)
expands to its services, each with its own count and effective
protected-days badge.

Verified live against real ClickHouse/Postgres and in-browser: a host
with a 7-day default plus a 365-day smtp override -- deleting nginx+
smtp+ufw together correctly removed nginx and ufw, left smtp's 10
records untouched, and confirmed via a follow-up owner delete that
bypassing the floor works. Also verified the full click-through (add a
service override on the agent page, see it reflected in Settings'
picker, select/preview/cancel) and confirmed no regression from the
prior host-only version's tests.
2026-08-21 15:54:58 -07:00

535 lines
20 KiB
Go

package logretention
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// targetCall records one CountOlderThan/DeleteOlderThan invocation, so
// tests can assert both the cutoff and the exact target set a call used.
type targetCall struct {
cutoff time.Time
targets []HostService
}
// fakeStore lets a test inject store errors and a fixed target listing,
// and records every count/delete call it received so tests can assert
// the handler scoped them to the right targets.
type fakeStore struct {
targetList []TargetCount
targetsErr error
count uint64
countErr error
deleteErr error
countedWith []targetCall
deletedWith []targetCall
}
func (f *fakeStore) TargetsOlderThan(_ context.Context, _ time.Time) ([]TargetCount, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
return f.targetList, nil
}
func (f *fakeStore) CountOlderThan(_ context.Context, cutoff time.Time, targets []HostService) (uint64, error) {
f.countedWith = append(f.countedWith, targetCall{cutoff, targets})
if f.countErr != nil {
return 0, f.countErr
}
return f.count, nil
}
func (f *fakeStore) DeleteOlderThan(_ context.Context, cutoff time.Time, targets []HostService) error {
f.deletedWith = append(f.deletedWith, targetCall{cutoff, targets})
return f.deleteErr
}
type fakeAuthorizer struct {
role authz.Role
}
func (f fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
return authz.Identity{TenantID: "default", UserID: "u1", Role: f.role}, nil
}
// fakeFloor stands in for AgentRetentionStore -- a nil/empty byHost map
// means no agent has any retention floor configured, same as every
// test that doesn't care about the floor assumed before it existed.
type fakeFloor struct {
byHost map[string]HostFloor
err error
}
func (f fakeFloor) FloorsByHost(context.Context) (map[string]HostFloor, error) {
return f.byHost, f.err
}
func newTestHandler(s *fakeStore, role authz.Role) *Handler {
return NewHandler(discardLogger(), s, fakeFloor{}, fakeAuthorizer{role: role})
}
func doRequest(t *testing.T, h *Handler, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
return rec
}
func doJSONRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(b))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
return rec
}
func hoursForDays(days int) int {
return days * 24
}
func intPtr(n int) *int { return &n }
func TestHostsListsTargetsGroupedByHostWithFloors(t *testing.T) {
s := &fakeStore{targetList: []TargetCount{
{Host: "web-01", Service: "nginx", Count: 100},
{Host: "web-01", Service: "smtp", Count: 5},
{Host: "web-02", Service: "ufw", Count: 20},
}}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {DefaultDays: intPtr(7), ServiceDays: map[string]int{"smtp": 365}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doRequest(t, h, "GET", "/logs/retention/hosts?older_than_hours=24")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var resp hostsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Hosts) != 2 {
t.Fatalf("len(hosts) = %d, want 2", len(resp.Hosts))
}
web01 := resp.Hosts[0]
if web01.Host != "web-01" || web01.ProtectedDays == nil || *web01.ProtectedDays != 7 {
t.Fatalf("hosts[0] = %+v, want web-01 with host default floor 7", web01)
}
if len(web01.Services) != 2 {
t.Fatalf("web-01 services = %+v, want 2 entries", web01.Services)
}
if web01.Services[0].Service != "nginx" || web01.Services[0].Count != 100 || web01.Services[0].ProtectedDays == nil || *web01.Services[0].ProtectedDays != 7 {
t.Errorf("web-01/nginx = %+v, want count=100 protected_days=7 (host default)", web01.Services[0])
}
if web01.Services[1].Service != "smtp" || web01.Services[1].ProtectedDays == nil || *web01.Services[1].ProtectedDays != 365 {
t.Errorf("web-01/smtp = %+v, want protected_days=365 (service override, not the host default)", web01.Services[1])
}
web02 := resp.Hosts[1]
if web02.Host != "web-02" || web02.ProtectedDays != nil {
t.Fatalf("hosts[1] = %+v, want web-02 with no floor", web02)
}
if len(web02.Services) != 1 || web02.Services[0].ProtectedDays != nil {
t.Errorf("web-02 services = %+v, want ufw with no floor", web02.Services)
}
}
func TestPreviewReturnsCountCutoffAndTargets(t *testing.T) {
s := &fakeStore{count: 42}
h := newTestHandler(s, authz.RoleAdmin)
before := time.Now().UTC()
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}, {Host: "web-01", Service: "smtp"}},
})
after := time.Now().UTC()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var resp previewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Count != 42 {
t.Errorf("count = %d, want 42", resp.Count)
}
want := []HostService{{Host: "web-01", Service: "nginx"}, {Host: "web-01", Service: "smtp"}}
if !reflect.DeepEqual(resp.Targets, want) {
t.Errorf("targets = %v, want %v", resp.Targets, want)
}
wantEarliest := before.Add(-24 * time.Hour)
wantLatest := after.Add(-24 * time.Hour)
if resp.Cutoff.Before(wantEarliest) || resp.Cutoff.After(wantLatest) {
t.Errorf("cutoff = %v, want between %v and %v", resp.Cutoff, wantEarliest, wantLatest)
}
if len(s.deletedWith) != 0 {
t.Errorf("preview must never delete anything, but DeleteOlderThan was called %d time(s)", len(s.deletedWith))
}
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].targets, want) {
t.Errorf("CountOlderThan was not scoped to the requested targets: %+v", s.countedWith)
}
}
func TestPreviewDedupesTargets(t *testing.T) {
s := &fakeStore{count: 1}
h := newTestHandler(s, authz.RoleAdmin)
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{
{Host: "web-01", Service: "nginx"},
{Host: "web-01", Service: "nginx"},
},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
want := []HostService{{Host: "web-01", Service: "nginx"}}
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].targets, want) {
t.Fatalf("expected a deduped single-target call, got %+v", s.countedWith)
}
}
func TestPreviewRequiresAtLeastOneTarget(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 with no targets specified", rec.Code)
}
}
func TestPreviewRejectsTargetWithEmptyHostOrService(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
cases := [][]HostService{
{{Host: "", Service: "nginx"}},
{{Host: "web-01", Service: ""}},
}
for _, targets := range cases {
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24, Targets: targets})
if rec.Code != http.StatusBadRequest {
t.Errorf("targets %v: status = %d, want 400", targets, rec.Code)
}
}
}
func TestPreviewRejectsInvalidOlderThanHours(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
targets := []HostService{{Host: "web-01", Service: "nginx"}}
cases := []int{0, -5, 999999999}
for _, hours := range cases {
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: hours, Targets: targets})
if rec.Code != http.StatusBadRequest {
t.Errorf("older_than_hours=%d: status = %d, want 400", hours, rec.Code)
}
}
}
func TestDeleteReturnsDeletedCountTargetsAndCutoff(t *testing.T) {
s := &fakeStore{count: 7}
h := newTestHandler(s, authz.RoleAdmin)
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 720,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var resp deleteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.DeletedCount != 7 {
t.Errorf("deleted_count = %d, want 7", resp.DeletedCount)
}
want := []HostService{{Host: "web-01", Service: "nginx"}}
if !reflect.DeepEqual(resp.DeletedTargets, want) {
t.Errorf("deleted_targets = %v, want %v", resp.DeletedTargets, want)
}
if len(s.deletedWith) != 1 || !reflect.DeepEqual(s.deletedWith[0].targets, want) {
t.Fatalf("expected exactly one scoped DeleteOlderThan call, got %+v", s.deletedWith)
}
if len(s.countedWith) != 1 || !s.countedWith[0].cutoff.Equal(s.deletedWith[0].cutoff) {
t.Errorf("count and delete must use the same cutoff: counted=%+v deleted=%+v", s.countedWith, s.deletedWith)
}
}
func TestDeleteRejectsMissingTargets(t *testing.T) {
s := &fakeStore{}
h := newTestHandler(s, authz.RoleAdmin)
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{OlderThanHours: 24})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 with no targets specified", rec.Code)
}
if len(s.deletedWith) != 0 {
t.Error("a request with no targets specified must never reach the store's delete path")
}
}
func TestDeleteRejectsInvalidJSONBody(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
req := httptest.NewRequest("POST", "/logs/retention/delete", bytes.NewReader([]byte("not json")))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestDeletePropagatesStoreErrors(t *testing.T) {
s := &fakeStore{deleteErr: errors.New("clickhouse mutation failed")}
h := newTestHandler(s, authz.RoleAdmin)
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}
func TestOwnerAndAdminCanUseRetentionRoutes(t *testing.T) {
targets := []HostService{{Host: "web-01", Service: "nginx"}}
for _, role := range []authz.Role{authz.RoleAdmin, authz.RoleOwner} {
s := &fakeStore{count: 3}
h := newTestHandler(s, role)
hosts := doRequest(t, h, "GET", "/logs/retention/hosts?older_than_hours=24")
if hosts.Code != http.StatusOK {
t.Errorf("role %s: hosts status = %d, want 200", role, hosts.Code)
}
preview := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24, Targets: targets})
if preview.Code != http.StatusOK {
t.Errorf("role %s: preview status = %d, want 200", role, preview.Code)
}
del := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{OlderThanHours: 24, Targets: targets})
if del.Code != http.StatusOK {
t.Errorf("role %s: delete status = %d, want 200", role, del.Code)
}
}
}
func TestViewerAndEditorAreForbiddenFromRetentionRoutes(t *testing.T) {
targets := []HostService{{Host: "web-01", Service: "nginx"}}
for _, role := range []authz.Role{authz.RoleViewer, authz.RoleEditor} {
s := &fakeStore{count: 3}
h := newTestHandler(s, role)
hosts := doRequest(t, h, "GET", "/logs/retention/hosts?older_than_hours=24")
if hosts.Code != http.StatusForbidden {
t.Errorf("role %s: hosts status = %d, want 403", role, hosts.Code)
}
preview := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24, Targets: targets})
if preview.Code != http.StatusForbidden {
t.Errorf("role %s: preview status = %d, want 403", role, preview.Code)
}
del := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{OlderThanHours: 24, Targets: targets})
if del.Code != http.StatusForbidden {
t.Errorf("role %s: delete status = %d, want 403", role, del.Code)
}
if len(s.deletedWith) != 0 {
t.Errorf("role %s: must never reach the store", role)
}
}
}
func TestRetentionRoutesRequireAuth(t *testing.T) {
s := &fakeStore{}
h := NewHandler(discardLogger(), s, fakeFloor{}, nil)
// A nil authorizer is Phase 0-3's default-open behavior (see
// authz.RequireRole's doc comment) -- confirm that posture applies
// here too, same as every other RequireRole-wrapped route, rather
// than this package accidentally being open or closed by default in
// a way inconsistent with the rest of the API.
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status with nil authorizer = %d, want 200 (default-open, matches RequireRole elsewhere)", rec.Code)
}
}
// TestAdminPartiallyBlockedByPerTargetRetentionFloor is the core
// regression test for target-scoped floor enforcement: requesting two
// targets where only one has a protective floor must delete the
// unprotected target and report the other as blocked, not reject the
// whole request.
func TestAdminPartiallyBlockedByPerTargetRetentionFloor(t *testing.T) {
s := &fakeStore{count: 5}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
// 30 days is newer than smtp's 90-day floor.
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(30),
Targets: []HostService{{Host: "web-01", Service: "smtp"}, {Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (partial success, not an error), body=%s", rec.Code, rec.Body.String())
}
var resp deleteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
wantDeleted := []HostService{{Host: "web-01", Service: "nginx"}}
if !reflect.DeepEqual(resp.DeletedTargets, wantDeleted) {
t.Errorf("deleted_targets = %v, want %v", resp.DeletedTargets, wantDeleted)
}
if len(resp.BlockedTargets) != 1 || resp.BlockedTargets[0] != (blockedTarget{Host: "web-01", Service: "smtp", ProtectedDays: 90}) {
t.Errorf("blocked_targets = %+v, want [{web-01 smtp 90}]", resp.BlockedTargets)
}
if len(s.deletedWith) != 1 || !reflect.DeepEqual(s.deletedWith[0].targets, wantDeleted) {
t.Fatalf("DeleteOlderThan must only ever be scoped to the allowed target, got %+v", s.deletedWith)
}
}
// TestServiceOverrideBeatsHostDefault confirms Effective's precedence:
// a service-specific floor applies over the host default even when the
// host default alone would have allowed the request.
func TestServiceOverrideBeatsHostDefault(t *testing.T) {
s := &fakeStore{count: 5}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {DefaultDays: intPtr(7), ServiceDays: map[string]int{"smtp": 365}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
// 30 days clears the 7-day host default but not smtp's 365-day
// override.
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(30),
Targets: []HostService{{Host: "web-01", Service: "smtp"}, {Host: "web-01", Service: "nginx"}},
})
var resp deleteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
wantDeleted := []HostService{{Host: "web-01", Service: "nginx"}}
if !reflect.DeepEqual(resp.DeletedTargets, wantDeleted) {
t.Errorf("deleted_targets = %v, want %v (nginx uses the 7-day default, smtp its own 365-day override)", resp.DeletedTargets, wantDeleted)
}
if len(resp.BlockedTargets) != 1 || resp.BlockedTargets[0].ProtectedDays != 365 {
t.Errorf("blocked_targets = %+v, want smtp blocked at 365 days", resp.BlockedTargets)
}
}
// TestAllTargetsBlockedReturnsZeroCountNotError confirms a request
// where every requested target is protected still succeeds (200), just
// with nothing deleted -- informative, not an error condition, since
// the request itself was perfectly valid.
func TestAllTargetsBlockedReturnsZeroCountNotError(t *testing.T) {
s := &fakeStore{count: 100}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(30),
Targets: []HostService{{Host: "web-01", Service: "smtp"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var resp deleteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.DeletedCount != 0 || len(resp.DeletedTargets) != 0 {
t.Errorf("deleted_count/targets = %d/%v, want 0/empty", resp.DeletedCount, resp.DeletedTargets)
}
if len(resp.BlockedTargets) != 1 {
t.Errorf("blocked_targets = %+v, want one entry", resp.BlockedTargets)
}
if len(s.deletedWith) != 0 || len(s.countedWith) != 0 {
t.Error("the store must never be called when every requested target is blocked")
}
}
// TestOwnerBypassesRetentionFloor confirms the whole point of the
// feature: an owner can still delete within a configured retention
// window that blocks everyone else.
func TestOwnerBypassesRetentionFloor(t *testing.T) {
s := &fakeStore{count: 100}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
}}, fakeAuthorizer{role: authz.RoleOwner})
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(1),
Targets: []HostService{{Host: "web-01", Service: "smtp"}},
})
var resp deleteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
want := []HostService{{Host: "web-01", Service: "smtp"}}
if !reflect.DeepEqual(resp.DeletedTargets, want) {
t.Errorf("deleted_targets = %v, want %v (owner bypasses the floor entirely)", resp.DeletedTargets, want)
}
}
// TestNoConfiguredFloorNeverBlocksAdmin confirms the default, common
// case (no agent has any retention floor set) behaves exactly as
// before this feature existed.
func TestNoConfiguredFloorNeverBlocksAdmin(t *testing.T) {
s := &fakeStore{count: 9}
h := NewHandler(discardLogger(), s, fakeFloor{}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 1,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with no configured floor", rec.Code)
}
}
func TestRetentionFloorCheckPropagatesStoreErrors(t *testing.T) {
s := &fakeStore{}
h := NewHandler(discardLogger(), s, fakeFloor{err: errors.New("postgres unreachable")}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}