Files
Redflag/aggregator-server/internal/database/queries/commands_ttl_test.go
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
Complete RedFlag codebase with two major security audit implementations.

== A-1: Ed25519 Key Rotation Support ==

Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management

Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing

== A-2: Replay Attack Fixes (F-1 through F-7) ==

F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH     - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH     - Migration 026: expires_at column with partial index
F-6 HIGH     - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH     - Agent-side executedIDs dedup map with cleanup
F-4 HIGH     - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt

Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.

All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:25:47 -04:00

155 lines
5.9 KiB
Go

package queries_test
// commands_ttl_test.go — Pre-fix tests for missing TTL in GetPendingCommands.
//
// These tests inspect the SQL query string for GetPendingCommands to document
// the absence of a time-bounding (TTL) filter (BUG F-6 / F-7).
//
// No live database is required — tests operate on the copied query string.
//
// Test categories:
// TestGetPendingCommandsHasNoTTLFilter PASS-NOW / FAIL-AFTER-FIX
// TestGetPendingCommandsMustHaveTTLFilter FAIL-NOW / PASS-AFTER-FIX
//
// IMPORTANT: When the fix is applied (TTL clause added to GetPendingCommands),
// update the getPendingCommandsQuery constant below to match the new query,
// and update the assertions in both tests accordingly.
//
// Run: cd aggregator-server && go test ./internal/database/queries/... -v -run TestGetPending
import (
"strings"
"testing"
)
// getPendingCommandsQuery is a verbatim copy of the query in
// queries/commands.go GetPendingCommands.
//
// POST-FIX (F-6 + F-7): TTL filter added via expires_at column.
// Commands past their expires_at are no longer returned to the agent.
const getPendingCommandsQuery = `
SELECT * FROM agent_commands
WHERE agent_id = $1 AND status = 'pending'
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at ASC
LIMIT 100
`
// ttlFilterIndicators lists the SQL tokens that would be present in a
// correctly time-bounded query. The absence of all of them confirms the bug.
var ttlFilterIndicators = []string{
"INTERVAL", // e.g. NOW() - INTERVAL '24 hours'
"expires_at", // if an expires_at column is added (F-7 fix)
}
// ---------------------------------------------------------------------------
// Test 3.1a — Documents that the bug exists
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// Asserts that getPendingCommandsQuery does NOT contain any TTL filter token.
// Currently PASSES (bug is present). Will FAIL when the fix adds a TTL clause
// and this constant is updated to match.
// ---------------------------------------------------------------------------
func TestGetPendingCommandsHasNoTTLFilter(t *testing.T) {
// POST-FIX (F-6 + F-7): TTL filter is now present via expires_at.
// This test now asserts the TTL indicator IS present (inverted from pre-fix).
hasTTL := false
for _, indicator := range ttlFilterIndicators {
if strings.Contains(getPendingCommandsQuery, indicator) {
hasTTL = true
t.Logf("POST-FIX: TTL indicator %q found in query", indicator)
}
}
if !hasTTL {
t.Errorf("F-6/F-7 FIX BROKEN: GetPendingCommands query should contain a TTL filter")
}
t.Log("F-6/F-7 FIXED: GetPendingCommands query now filters by expires_at.")
t.Log("Query text:")
t.Log(getPendingCommandsQuery)
}
// ---------------------------------------------------------------------------
// Test 3.1b — Asserts the correct post-fix behaviour
//
// Category: FAIL-NOW / PASS-AFTER-FIX
//
// Asserts that getPendingCommandsQuery DOES contain a TTL filter.
// Currently FAILS (bug is present). Will PASS once the fix is applied and
// this constant is updated.
// ---------------------------------------------------------------------------
func TestGetPendingCommandsMustHaveTTLFilter(t *testing.T) {
// POST-FIX (F-6 + F-7): This test now PASSES.
// The query contains expires_at TTL filter.
hasTTL := false
for _, indicator := range ttlFilterIndicators {
if strings.Contains(getPendingCommandsQuery, indicator) {
hasTTL = true
break
}
}
if !hasTTL {
t.Errorf("GetPendingCommands query must contain a TTL filter (expires_at or INTERVAL)")
}
}
// ---------------------------------------------------------------------------
// Test 3.2 — Complementary: RetryCommand copies Params but not signature
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// Independently of the DB, documents that queries.RetryCommand (commands.go:189)
// builds a new AgentCommand without propagating Signature, SignedAt, or KeyID.
// This is a query-level confirmation of BUG F-5, placed here because it
// exercises the same file (commands.go).
// ---------------------------------------------------------------------------
func TestRetryCommandQueryDoesNotCopySignature(t *testing.T) {
// BUG F-5 (query layer): RetryCommand builds a new command without signing.
// The INSERT that follows has Signature="", SignedAt=nil, KeyID="" in the row.
// Verify by inspecting what fields the struct would have after RetryCommand.
// (Struct construction from commands.go:202)
//
// newCommand := &models.AgentCommand{
// ID: uuid.New(),
// AgentID: original.AgentID,
// CommandType: original.CommandType,
// Params: original.Params, ← Params copied
// Status: models.CommandStatusPending,
// CreatedAt: time.Now(),
// RetriedFromID: &originalID,
// // Signature: NOT copied — zero value ""
// // SignedAt: NOT copied — zero value nil
// // KeyID: NOT copied — zero value ""
// }
//
// The INSERT query in CreateCommand (commands.go:38) includes :signature,
// :key_id, :signed_at in its column list — they will be stored as
// NULL / empty string, which is the unfixed state.
// Document the field names in the INSERT that are left empty by RetryCommand.
retryCreatesFields := []string{"id", "agent_id", "command_type", "params", "status", "retried_from_id"}
retryOmitsFields := []string{"signature", "key_id", "signed_at"}
// These will be empty/nil in the created command (confirms bug).
for _, f := range retryOmitsFields {
t.Logf("BUG F-5: RetryCommand does not set field: %q (will be empty/nil)", f)
}
for _, f := range retryCreatesFields {
t.Logf(" RetryCommand does set field: %q", f)
}
// This test always passes — it's purely documentary.
// It will need to be updated when the fix is applied (RetryCommand will then set all three).
t.Log("BUG F-5 CONFIRMED (query layer): Retry path omits signature, key_id, and signed_at.")
t.Log("After fix: all three fields must be populated by a signing call in RetryCommand.")
}