Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
392 changes: 210 additions & 182 deletions .secrets.baseline

Large diffs are not rendered by default.

47 changes: 44 additions & 3 deletions app/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -686,16 +686,16 @@ paths:
/api/v1/hosts/{id}:
get:
operationId: getHostByID
summary: Fetch a host
summary: Fetch a host with liveness + compliance enrichment
x-required-permission: host:read
parameters:
- {name: id, in: path, required: true, schema: {type: string, format: uuid}}
responses:
'200':
description: Host
description: Host detail (includes liveness + compliance_summary)
content:
application/json:
schema: {$ref: '#/components/schemas/HostResponse'}
schema: {$ref: '#/components/schemas/HostDetailResponse'}
'404':
description: Host not found
content:
Expand Down Expand Up @@ -1091,6 +1091,47 @@ components:
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}

# Per-host enrichment (spec api-hosts v1.1.0 C-05).
# Populated on GET /hosts/{id} when host_liveness has a row for the host.
HostLiveness:
type: object
required: [reachability_status]
properties:
reachability_status:
type: string
enum: [reachable, unreachable, unknown]
last_probe_at: {type: string, format: date-time, nullable: true}
last_response_ms: {type: integer, nullable: true}
consecutive_failures: {type: integer}
last_state_change_at: {type: string, format: date-time, nullable: true}
last_error_type: {type: string, nullable: true}

# Per-host compliance roll-up (spec api-hosts v1.1.0 C-06).
# Always populated on GET /hosts/{id} — zero counts when no rule_state.
HostComplianceSummary:
type: object
required: [passing, failing, skipped, error, total]
properties:
passing: {type: integer, format: int64}
failing: {type: integer, format: int64}
skipped: {type: integer, format: int64}
error: {type: integer, format: int64}
total: {type: integer, format: int64}

# Detail-view response: HostResponse plus enrichments. Used by
# GET /hosts/{id} only; the list endpoint keeps HostResponse to
# avoid per-host join overhead on bulk reads.
HostDetailResponse:
type: object
required: [host, compliance_summary]
properties:
host: {$ref: '#/components/schemas/HostResponse'}
liveness:
allOf: [{$ref: '#/components/schemas/HostLiveness'}]
nullable: true
description: Null when no liveness probe has ever run against this host.
compliance_summary: {$ref: '#/components/schemas/HostComplianceSummary'}

HostListResponse:
type: object
required: [hosts]
Expand Down
240 changes: 148 additions & 92 deletions app/internal/server/api/server.gen.go

Large diffs are not rendered by default.

189 changes: 189 additions & 0 deletions app/internal/server/api_hosts_enrichment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// @spec api-hosts
//
// AC traceability (this file):
// AC-13 TestHosts_GetByID_Enrichment_LivenessPresent
// AC-14 TestHosts_GetByID_Enrichment_LivenessNullWhenUnprobed
// AC-15 TestHosts_GetByID_Enrichment_ComplianceSummaryCounts
// AC-16 TestHosts_GetByID_Enrichment_ComplianceSummaryZerosOnEmpty

package server

import (
"context"
"encoding/json"
"net/http"
"testing"
"time"

"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"

"github.com/Hanalyx/openwatch/internal/auth"
)

// seedLivenessForHost inserts a host_liveness row for the host the
// preceding createHostAPI call returned.
func seedLivenessForHost(t *testing.T, pool *pgxpool.Pool, hostID uuid.UUID, status string, lastProbeAt time.Time, consecutiveFails int) {
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO host_liveness
(host_id, reachability_status, last_probe_at,
consecutive_failures, last_state_change_at, updated_at)
VALUES ($1, $2, $3, $4, $3, $3)`,
hostID, status, lastProbeAt, consecutiveFails,
)
if err != nil {
t.Fatalf("seed host_liveness: %v", err)
}
}

// seedRuleStateForHost inserts a single host_rule_state row.
func seedRuleStateForHost(t *testing.T, pool *pgxpool.Pool, hostID uuid.UUID, ruleID, status string) {
t.Helper()
now := time.Now().UTC()
scanID, _ := uuid.NewV7()
_, err := pool.Exec(context.Background(), `
INSERT INTO host_rule_state
(host_id, rule_id, current_status, severity,
last_checked_at, check_count, last_scan_id, evidence,
framework_refs, first_seen_at, last_changed_at)
VALUES ($1, $2, $3, 'medium', $4, 1, $5, '{}'::jsonb, '{}'::jsonb, $4, $4)`,
hostID, ruleID, status, now, scanID,
)
if err != nil {
t.Fatalf("seed host_rule_state: %v", err)
}
}

// hostDetailResponse mirrors the shape returned by GET /hosts/{id}
// after the v1.1.0 enrichment. The decoded body uses map[string]any
// for flexibility — strongly-typed unmarshal would require an oapi-
// generated client which lives outside this test surface.
type hostDetailResponse struct {
Host map[string]any `json:"host"`
Liveness *struct {
ReachabilityStatus string `json:"reachability_status"`
LastProbeAt *time.Time `json:"last_probe_at"`
LastResponseMs *int `json:"last_response_ms"`
ConsecutiveFailures *int `json:"consecutive_failures"`
} `json:"liveness"`
ComplianceSummary struct {
Passing int64 `json:"passing"`
Failing int64 `json:"failing"`
Skipped int64 `json:"skipped"`
Error int64 `json:"error"`
Total int64 `json:"total"`
} `json:"compliance_summary"`
}

func getHostDetail(t *testing.T, url, hostID string) hostDetailResponse {
t.Helper()
req := asRole(t, "GET", url+"/api/v1/hosts/"+hostID, auth.RoleAdmin, nil)
resp := doReq(t, req)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got hostDetailResponse
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
return got
}

// @ac AC-13
// AC-13: host with a host_liveness row → liveness populated.
func TestHosts_GetByID_Enrichment_LivenessPresent(t *testing.T) {
t.Run("api-hosts/AC-13", func(t *testing.T) {
url, pool := freshAPIServer(t)
created := createHostAPI(t, url, "live-host", "production")
idStr := created["id"].(string)
hostID, _ := uuid.Parse(idStr)
probedAt := time.Now().UTC().Truncate(time.Second)
seedLivenessForHost(t, pool, hostID, "reachable", probedAt, 0)

got := getHostDetail(t, url, idStr)
if got.Liveness == nil {
t.Fatal("liveness is null; want populated")
}
if got.Liveness.ReachabilityStatus != "reachable" {
t.Errorf("reachability_status = %q, want reachable", got.Liveness.ReachabilityStatus)
}
if got.Liveness.LastProbeAt == nil {
t.Error("last_probe_at is nil; want populated")
}
})
}

// @ac AC-14
// AC-14: host with no host_liveness row → liveness=null, not an error.
func TestHosts_GetByID_Enrichment_LivenessNullWhenUnprobed(t *testing.T) {
t.Run("api-hosts/AC-14", func(t *testing.T) {
url, _ := freshAPIServer(t)
created := createHostAPI(t, url, "unprobed-host", "production")
idStr := created["id"].(string)

got := getHostDetail(t, url, idStr)
if got.Liveness != nil {
t.Errorf("liveness = %+v, want nil for unprobed host", got.Liveness)
}
// compliance_summary still present (default zeros), not null.
if got.ComplianceSummary.Total != 0 {
t.Errorf("compliance_summary.total = %d, want 0", got.ComplianceSummary.Total)
}
})
}

// @ac AC-15
// AC-15: host with mixed host_rule_state → compliance_summary counts
// match.
func TestHosts_GetByID_Enrichment_ComplianceSummaryCounts(t *testing.T) {
t.Run("api-hosts/AC-15", func(t *testing.T) {
url, pool := freshAPIServer(t)
created := createHostAPI(t, url, "mixed-host", "production")
idStr := created["id"].(string)
hostID, _ := uuid.Parse(idStr)
seedRuleStateForHost(t, pool, hostID, "rule.p1", "pass")
seedRuleStateForHost(t, pool, hostID, "rule.p2", "pass")
seedRuleStateForHost(t, pool, hostID, "rule.f1", "fail")
seedRuleStateForHost(t, pool, hostID, "rule.f2", "fail")
seedRuleStateForHost(t, pool, hostID, "rule.f3", "fail")
seedRuleStateForHost(t, pool, hostID, "rule.s1", "skipped")
seedRuleStateForHost(t, pool, hostID, "rule.e1", "error")

got := getHostDetail(t, url, idStr)
s := got.ComplianceSummary
if s.Passing != 2 {
t.Errorf("passing = %d, want 2", s.Passing)
}
if s.Failing != 3 {
t.Errorf("failing = %d, want 3", s.Failing)
}
if s.Skipped != 1 {
t.Errorf("skipped = %d, want 1", s.Skipped)
}
if s.Error != 1 {
t.Errorf("error = %d, want 1", s.Error)
}
if s.Total != 7 {
t.Errorf("total = %d, want 7", s.Total)
}
})
}

// @ac AC-16
// AC-16: host with no host_rule_state rows → compliance_summary all zeros, not null, not error.
func TestHosts_GetByID_Enrichment_ComplianceSummaryZerosOnEmpty(t *testing.T) {
t.Run("api-hosts/AC-16", func(t *testing.T) {
url, _ := freshAPIServer(t)
created := createHostAPI(t, url, "no-rules-host", "production")
idStr := created["id"].(string)

got := getHostDetail(t, url, idStr)
s := got.ComplianceSummary
// All zero, valid JSON object (not null), no error.
if s.Passing != 0 || s.Failing != 0 || s.Skipped != 0 || s.Error != 0 || s.Total != 0 {
t.Errorf("got %+v, want all zeros", s)
}
})
}
16 changes: 10 additions & 6 deletions app/internal/server/api_hosts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,13 @@ func TestHosts_GetByID(t *testing.T) {
resp.Body.Close()
t.Fatalf("get status = %d body=%s", resp.StatusCode, b)
}
var got map[string]any
var got struct {
Host map[string]any `json:"host"`
}
_ = json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if got["hostname"] != "by-id" {
t.Errorf("hostname = %v, want by-id", got["hostname"])
if got.Host["hostname"] != "by-id" {
t.Errorf("host.hostname = %v, want by-id", got.Host["hostname"])
}

// Unknown id → 404.
Expand Down Expand Up @@ -388,11 +390,13 @@ func TestHosts_Patch_InvalidIP(t *testing.T) {
// Confirm IP wasn't mutated by re-reading.
req = asRole(t, "GET", url+"/api/v1/hosts/"+id, auth.RoleAdmin, nil)
resp = doReq(t, req)
var got map[string]any
var got struct {
Host map[string]any `json:"host"`
}
_ = json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if got["ip_address"] != origIP {
t.Errorf("ip_address mutated despite 400: %v vs %v", got["ip_address"], origIP)
if got.Host["ip_address"] != origIP {
t.Errorf("ip_address mutated despite 400: %v vs %v", got.Host["ip_address"], origIP)
}
})
}
Expand Down
74 changes: 74 additions & 0 deletions app/internal/server/hosts_enrichment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package server

import (
"context"
"errors"
"fmt"
"time"

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"

"github.com/Hanalyx/openwatch/internal/server/api"
)

// loadHostLiveness reads the host_liveness row for the given host, or
// returns (nil, nil) when no row exists. Spec api-hosts C-05 / AC-13 /
// AC-14.
func loadHostLiveness(ctx context.Context, pool *pgxpool.Pool, hostID uuid.UUID) (*api.HostLiveness, error) {
const q = `
SELECT reachability_status, last_probe_at, last_response_ms,
consecutive_failures, last_state_change_at, last_error_type
FROM host_liveness
WHERE host_id = $1`
var (
status string
lastProbeAt *time.Time
lastResponseMS *int
consecutiveFails int
lastStateChangeAt *time.Time
lastErrorType *string
)
err := pool.QueryRow(ctx, q, hostID).Scan(
&status, &lastProbeAt, &lastResponseMS,
&consecutiveFails, &lastStateChangeAt, &lastErrorType,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil // AC-14: no liveness row → null in response
}
return nil, fmt.Errorf("loadHostLiveness: %w", err)
}
out := &api.HostLiveness{
ReachabilityStatus: api.HostLivenessReachabilityStatus(status),
ConsecutiveFailures: &consecutiveFails,
LastProbeAt: lastProbeAt,
LastResponseMs: lastResponseMS,
LastStateChangeAt: lastStateChangeAt,
LastErrorType: lastErrorType,
}
return out, nil
}

// loadHostComplianceSummary reads the per-status counts from
// host_rule_state for the given host. A host with no rule_state rows
// returns all zeros — never an error. Spec api-hosts C-06 / AC-16.
func loadHostComplianceSummary(ctx context.Context, pool *pgxpool.Pool, hostID uuid.UUID) (api.HostComplianceSummary, error) {
const q = `
SELECT
COUNT(*) FILTER (WHERE current_status = 'pass')::BIGINT AS passing,
COUNT(*) FILTER (WHERE current_status = 'fail')::BIGINT AS failing,
COUNT(*) FILTER (WHERE current_status = 'skipped')::BIGINT AS skipped,
COUNT(*) FILTER (WHERE current_status = 'error')::BIGINT AS errors,
COUNT(*)::BIGINT AS total
FROM host_rule_state
WHERE host_id = $1`
var s api.HostComplianceSummary
if err := pool.QueryRow(ctx, q, hostID).Scan(
&s.Passing, &s.Failing, &s.Skipped, &s.Error, &s.Total,
); err != nil {
return api.HostComplianceSummary{}, fmt.Errorf("loadHostComplianceSummary: %w", err)
}
return s, nil
}
Loading
Loading