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.
This commit is contained in:
+249
-163
@@ -1,6 +1,7 @@
|
||||
package logretention
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,43 +20,43 @@ func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// hostCall records one CountOlderThan/DeleteOlderThan invocation, so
|
||||
// tests can assert both the cutoff and the exact host set a call used.
|
||||
type hostCall struct {
|
||||
cutoff time.Time
|
||||
hosts []string
|
||||
// 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 host listing,
|
||||
// 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 hosts.
|
||||
// the handler scoped them to the right targets.
|
||||
type fakeStore struct {
|
||||
hostList []HostCount
|
||||
hostsErr error
|
||||
targetList []TargetCount
|
||||
targetsErr error
|
||||
count uint64
|
||||
countErr error
|
||||
deleteErr error
|
||||
countedWith []hostCall
|
||||
deletedWith []hostCall
|
||||
countedWith []targetCall
|
||||
deletedWith []targetCall
|
||||
}
|
||||
|
||||
func (f *fakeStore) HostsOlderThan(_ context.Context, _ time.Time) ([]HostCount, error) {
|
||||
if f.hostsErr != nil {
|
||||
return nil, f.hostsErr
|
||||
func (f *fakeStore) TargetsOlderThan(_ context.Context, _ time.Time) ([]TargetCount, error) {
|
||||
if f.targetsErr != nil {
|
||||
return nil, f.targetsErr
|
||||
}
|
||||
return f.hostList, nil
|
||||
return f.targetList, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CountOlderThan(_ context.Context, cutoff time.Time, hosts []string) (uint64, error) {
|
||||
f.countedWith = append(f.countedWith, hostCall{cutoff, hosts})
|
||||
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, hosts []string) error {
|
||||
f.deletedWith = append(f.deletedWith, hostCall{cutoff, hosts})
|
||||
func (f *fakeStore) DeleteOlderThan(_ context.Context, cutoff time.Time, targets []HostService) error {
|
||||
f.deletedWith = append(f.deletedWith, targetCall{cutoff, targets})
|
||||
return f.deleteErr
|
||||
}
|
||||
|
||||
@@ -69,14 +69,14 @@ func (f fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
|
||||
}
|
||||
|
||||
// fakeFloor stands in for AgentRetentionStore -- a nil/empty byHost map
|
||||
// means no agent has log_retention_days configured, same as every test
|
||||
// that doesn't care about the floor assumed before it existed.
|
||||
// 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]int
|
||||
byHost map[string]HostFloor
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeFloor) RetentionDaysByHost(context.Context) (map[string]int, error) {
|
||||
func (f fakeFloor) FloorsByHost(context.Context) (map[string]HostFloor, error) {
|
||||
return f.byHost, f.err
|
||||
}
|
||||
|
||||
@@ -94,13 +94,35 @@ func doRequest(t *testing.T, h *Handler, method, path string) *httptest.Response
|
||||
return rec
|
||||
}
|
||||
|
||||
func hoursForDays(days int) string {
|
||||
return strconv.Itoa(days * 24)
|
||||
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 TestHostsListsHostsWithCountsAndFloors(t *testing.T) {
|
||||
s := &fakeStore{hostList: []HostCount{{Host: "web-01", Count: 100}, {Host: "web-02", Count: 5}}}
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"web-02": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
|
||||
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 {
|
||||
@@ -113,20 +135,39 @@ func TestHostsListsHostsWithCountsAndFloors(t *testing.T) {
|
||||
if len(resp.Hosts) != 2 {
|
||||
t.Fatalf("len(hosts) = %d, want 2", len(resp.Hosts))
|
||||
}
|
||||
if resp.Hosts[0].Host != "web-01" || resp.Hosts[0].Count != 100 || resp.Hosts[0].ProtectedDays != nil {
|
||||
t.Errorf("hosts[0] = %+v, want web-01/100/no floor", resp.Hosts[0])
|
||||
|
||||
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 resp.Hosts[1].Host != "web-02" || resp.Hosts[1].Count != 5 || resp.Hosts[1].ProtectedDays == nil || *resp.Hosts[1].ProtectedDays != 90 {
|
||||
t.Errorf("hosts[1] = %+v, want web-02/5/floor=90", resp.Hosts[1])
|
||||
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 TestPreviewReturnsCountCutoffAndHosts(t *testing.T) {
|
||||
func TestPreviewReturnsCountCutoffAndTargets(t *testing.T) {
|
||||
s := &fakeStore{count: 42}
|
||||
h := newTestHandler(s, authz.RoleAdmin)
|
||||
|
||||
before := time.Now().UTC()
|
||||
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01&host=web-02")
|
||||
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 {
|
||||
@@ -139,8 +180,9 @@ func TestPreviewReturnsCountCutoffAndHosts(t *testing.T) {
|
||||
if resp.Count != 42 {
|
||||
t.Errorf("count = %d, want 42", resp.Count)
|
||||
}
|
||||
if !reflect.DeepEqual(resp.Hosts, []string{"web-01", "web-02"}) {
|
||||
t.Errorf("hosts = %v, want [web-01 web-02]", resp.Hosts)
|
||||
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)
|
||||
@@ -150,47 +192,76 @@ func TestPreviewReturnsCountCutoffAndHosts(t *testing.T) {
|
||||
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].hosts, []string{"web-01", "web-02"}) {
|
||||
t.Errorf("CountOlderThan was not scoped to the requested hosts: %+v", s.countedWith)
|
||||
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 TestPreviewDedupesHosts(t *testing.T) {
|
||||
func TestPreviewDedupesTargets(t *testing.T) {
|
||||
s := &fakeStore{count: 1}
|
||||
h := newTestHandler(s, authz.RoleAdmin)
|
||||
|
||||
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01&host=web-01")
|
||||
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())
|
||||
}
|
||||
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].hosts, []string{"web-01"}) {
|
||||
t.Fatalf("expected a deduped single-host call, got %+v", s.countedWith)
|
||||
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 TestPreviewRequiresAtLeastOneHost(t *testing.T) {
|
||||
func TestPreviewRequiresAtLeastOneTarget(t *testing.T) {
|
||||
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
|
||||
|
||||
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24")
|
||||
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 with no host specified", rec.Code)
|
||||
t.Fatalf("status = %d, want 400 with no targets specified", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewRejectsEmptyHostValue(t *testing.T) {
|
||||
func TestPreviewRejectsTargetWithEmptyHostOrService(t *testing.T) {
|
||||
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
|
||||
|
||||
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 with an empty host value", rec.Code)
|
||||
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 TestDeleteReturnsDeletedCountHostsAndCutoff(t *testing.T) {
|
||||
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 := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=720&host=web-01")
|
||||
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())
|
||||
}
|
||||
@@ -201,10 +272,11 @@ func TestDeleteReturnsDeletedCountHostsAndCutoff(t *testing.T) {
|
||||
if resp.DeletedCount != 7 {
|
||||
t.Errorf("deleted_count = %d, want 7", resp.DeletedCount)
|
||||
}
|
||||
if !reflect.DeepEqual(resp.DeletedHosts, []string{"web-01"}) {
|
||||
t.Errorf("deleted_hosts = %v, want [web-01]", resp.DeletedHosts)
|
||||
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].hosts, []string{"web-01"}) {
|
||||
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) {
|
||||
@@ -212,61 +284,47 @@ func TestDeleteReturnsDeletedCountHostsAndCutoff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsMissingOrInvalidOlderThanHours(t *testing.T) {
|
||||
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)
|
||||
|
||||
cases := []string{
|
||||
"/logs/retention/preview?host=web-01",
|
||||
"/logs/retention/preview?older_than_hours=0&host=web-01",
|
||||
"/logs/retention/preview?older_than_hours=-5&host=web-01",
|
||||
"/logs/retention/preview?older_than_hours=notanumber&host=web-01",
|
||||
"/logs/retention/preview?older_than_hours=999999999&host=web-01",
|
||||
}
|
||||
for _, path := range cases {
|
||||
rec := doRequest(t, h, "GET", path)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("path %q: status = %d, want 400", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRejectsInvalidOlderThanHours(t *testing.T) {
|
||||
s := &fakeStore{}
|
||||
h := newTestHandler(s, authz.RoleAdmin)
|
||||
|
||||
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=0&host=web-01")
|
||||
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)
|
||||
}
|
||||
if len(s.deletedWith) != 0 {
|
||||
t.Error("an invalid older_than_hours must never reach the store's delete path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRejectsMissingHosts(t *testing.T) {
|
||||
s := &fakeStore{}
|
||||
h := newTestHandler(s, authz.RoleAdmin)
|
||||
|
||||
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 with no host specified", rec.Code)
|
||||
}
|
||||
if len(s.deletedWith) != 0 {
|
||||
t.Error("a request with no host specified must never reach the store's delete path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePropagatesStoreErrors(t *testing.T) {
|
||||
s := &fakeStore{deleteErr: errors.New("clickhouse mutation failed")}
|
||||
h := newTestHandler(s, authz.RoleAdmin)
|
||||
|
||||
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
|
||||
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)
|
||||
@@ -275,11 +333,11 @@ func TestOwnerAndAdminCanUseRetentionRoutes(t *testing.T) {
|
||||
if hosts.Code != http.StatusOK {
|
||||
t.Errorf("role %s: hosts status = %d, want 200", role, hosts.Code)
|
||||
}
|
||||
preview := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01")
|
||||
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 := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
|
||||
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)
|
||||
}
|
||||
@@ -287,6 +345,7 @@ func TestOwnerAndAdminCanUseRetentionRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -295,11 +354,11 @@ func TestViewerAndEditorAreForbiddenFromRetentionRoutes(t *testing.T) {
|
||||
if hosts.Code != http.StatusForbidden {
|
||||
t.Errorf("role %s: hosts status = %d, want 403", role, hosts.Code)
|
||||
}
|
||||
preview := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01")
|
||||
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 := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
|
||||
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)
|
||||
}
|
||||
@@ -318,23 +377,31 @@ func TestRetentionRoutesRequireAuth(t *testing.T) {
|
||||
// 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 := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPartiallyBlockedByPerHostRetentionFloor is the core
|
||||
// regression test for host-scoped floor enforcement: requesting two
|
||||
// hosts where only one has a protective floor must delete the
|
||||
// unprotected host and report the other as blocked, not reject the
|
||||
// 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 TestAdminPartiallyBlockedByPerHostRetentionFloor(t *testing.T) {
|
||||
func TestAdminPartiallyBlockedByPerTargetRetentionFloor(t *testing.T) {
|
||||
s := &fakeStore{count: 5}
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"protected-host": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
|
||||
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 protected-host's 90-day floor.
|
||||
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(30)+"&host=protected-host&host=open-host")
|
||||
// 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())
|
||||
}
|
||||
@@ -342,26 +409,60 @@ func TestAdminPartiallyBlockedByPerHostRetentionFloor(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(resp.DeletedHosts, []string{"open-host"}) {
|
||||
t.Errorf("deleted_hosts = %v, want [open-host]", resp.DeletedHosts)
|
||||
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.BlockedHosts) != 1 || resp.BlockedHosts[0].Host != "protected-host" || resp.BlockedHosts[0].ProtectedDays != 90 {
|
||||
t.Errorf("blocked_hosts = %+v, want [{protected-host 90}]", resp.BlockedHosts)
|
||||
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].hosts, []string{"open-host"}) {
|
||||
t.Fatalf("DeleteOlderThan must only ever be scoped to the allowed host, got %+v", s.deletedWith)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllHostsBlockedReturnsZeroCountNotError confirms a request where
|
||||
// every requested host is protected still succeeds (200), just with
|
||||
// nothing deleted -- informative, not an error condition, since the
|
||||
// request itself was perfectly valid.
|
||||
func TestAllHostsBlockedReturnsZeroCountNotError(t *testing.T) {
|
||||
s := &fakeStore{count: 100}
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"protected-host": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
|
||||
// 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})
|
||||
|
||||
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(30)+"&host=protected-host")
|
||||
// 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())
|
||||
}
|
||||
@@ -369,38 +470,14 @@ func TestAllHostsBlockedReturnsZeroCountNotError(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if resp.DeletedCount != 0 {
|
||||
t.Errorf("deleted_count = %d, want 0", resp.DeletedCount)
|
||||
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.DeletedHosts) != 0 {
|
||||
t.Errorf("deleted_hosts = %v, want empty", resp.DeletedHosts)
|
||||
}
|
||||
if len(resp.BlockedHosts) != 1 || resp.BlockedHosts[0].Host != "protected-host" {
|
||||
t.Errorf("blocked_hosts = %+v, want [{protected-host 90}]", resp.BlockedHosts)
|
||||
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 host is blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAllowedBeyondRetentionFloor confirms the floor only blocks
|
||||
// requests that would actually reach into the protected window -- a
|
||||
// request older than the floor itself is unaffected by it.
|
||||
func TestAdminAllowedBeyondRetentionFloor(t *testing.T) {
|
||||
s := &fakeStore{count: 5}
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"web-01": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
|
||||
|
||||
// 120 days is older than the 90-day floor -- must be allowed.
|
||||
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(120)+"&host=web-01")
|
||||
if del.Code != http.StatusOK {
|
||||
t.Fatalf("delete at 120d against a 90d floor: status = %d, want 200, body=%s", del.Code, del.Body.String())
|
||||
}
|
||||
var resp deleteResponse
|
||||
if err := json.Unmarshal(del.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if len(resp.BlockedHosts) != 0 {
|
||||
t.Errorf("blocked_hosts = %+v, want none", resp.BlockedHosts)
|
||||
t.Error("the store must never be called when every requested target is blocked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,31 +486,37 @@ func TestAdminAllowedBeyondRetentionFloor(t *testing.T) {
|
||||
// window that blocks everyone else.
|
||||
func TestOwnerBypassesRetentionFloor(t *testing.T) {
|
||||
s := &fakeStore{count: 100}
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"web-01": 90}}, fakeAuthorizer{role: authz.RoleOwner})
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
|
||||
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
|
||||
}}, fakeAuthorizer{role: authz.RoleOwner})
|
||||
|
||||
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(1)+"&host=web-01")
|
||||
if del.Code != http.StatusOK {
|
||||
t.Fatalf("owner deleting within the floor: status = %d, want 200, body=%s", del.Code, del.Body.String())
|
||||
}
|
||||
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(del.Body.Bytes(), &resp); err != nil {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(resp.DeletedHosts, []string{"web-01"}) {
|
||||
t.Errorf("deleted_hosts = %v, want [web-01] (owner bypasses the floor entirely)", resp.DeletedHosts)
|
||||
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 log_retention_days set) behaves exactly as before
|
||||
// this feature existed.
|
||||
// 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})
|
||||
|
||||
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=1&host=web-01")
|
||||
if del.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 with no configured floor", del.Code)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +524,10 @@ func TestRetentionFloorCheckPropagatesStoreErrors(t *testing.T) {
|
||||
s := &fakeStore{}
|
||||
h := NewHandler(discardLogger(), s, fakeFloor{err: errors.New("postgres unreachable")}, fakeAuthorizer{role: authz.RoleAdmin})
|
||||
|
||||
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01")
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user