|
|
|
|
package mock
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"tera-clone/services/tera-api/internal/domain"
|
|
|
|
|
"tera-clone/services/tera-api/internal/logservice"
|
|
|
|
|
"tera-clone/services/tera-api/internal/logstorage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type memoryJournal struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
records map[string][]json.RawMessage
|
|
|
|
|
retentions []logstorage.Retention
|
|
|
|
|
snapshot logstorage.Snapshot
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *memoryJournal) Append(class string, _ time.Time, value any) error {
|
|
|
|
|
j.mu.Lock()
|
|
|
|
|
defer j.mu.Unlock()
|
|
|
|
|
if j.records == nil {
|
|
|
|
|
j.records = make(map[string][]json.RawMessage)
|
|
|
|
|
}
|
|
|
|
|
payload, err := json.Marshal(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
j.records[class] = append(j.records[class], payload)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *memoryJournal) Read(class string) ([]json.RawMessage, error) {
|
|
|
|
|
j.mu.Lock()
|
|
|
|
|
defer j.mu.Unlock()
|
|
|
|
|
result := make([]json.RawMessage, len(j.records[class]))
|
|
|
|
|
for index := range result {
|
|
|
|
|
result[index] = append(json.RawMessage(nil), j.records[class][index]...)
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *memoryJournal) ApplyRetention(retention logstorage.Retention) error {
|
|
|
|
|
j.mu.Lock()
|
|
|
|
|
defer j.mu.Unlock()
|
|
|
|
|
j.retentions = append(j.retentions, retention)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *memoryJournal) Snapshot() (logstorage.Snapshot, error) {
|
|
|
|
|
j.mu.Lock()
|
|
|
|
|
defer j.mu.Unlock()
|
|
|
|
|
return j.snapshot, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOperationalStoragePressureAlarmIsEdgeTriggered(t *testing.T) {
|
|
|
|
|
journal := &memoryJournal{snapshot: logstorage.Snapshot{
|
|
|
|
|
UsageBytes: map[string]int64{"audit": 20, "system": 30, "port": 41},
|
|
|
|
|
CapBytes: map[string]int64{"audit": 20, "system": 30, "port": 50},
|
|
|
|
|
}}
|
|
|
|
|
controller := &recordingAlarmDeliveryController{}
|
|
|
|
|
store, err := NewStoreWithOptions(fixedClock, StoreOptions{LogJournal: journal, AlarmDeliveryController: controller})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.EvaluateOperationalAlarms(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.EvaluateOperationalAlarms(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(controller.requests) != 1 || controller.requests[0].RuleID != "storage_pressure" {
|
|
|
|
|
t.Fatalf("storage pressure requests = %+v, want one edge-triggered alarm", controller.requests)
|
|
|
|
|
}
|
|
|
|
|
journal.mu.Lock()
|
|
|
|
|
journal.snapshot.UsageBytes["port"] = 20
|
|
|
|
|
journal.mu.Unlock()
|
|
|
|
|
if err := store.EvaluateOperationalAlarms(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
journal.mu.Lock()
|
|
|
|
|
journal.snapshot.UsageBytes["port"] = 41
|
|
|
|
|
journal.mu.Unlock()
|
|
|
|
|
if err := store.EvaluateOperationalAlarms(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(controller.requests) != 2 {
|
|
|
|
|
t.Fatalf("storage pressure did not rearm after recovery: %+v", controller.requests)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type sequencedMemoryJournal struct {
|
|
|
|
|
memoryJournal
|
|
|
|
|
sequences map[string][]uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *sequencedMemoryJournal) Append(class string, timestamp time.Time, value any) error {
|
|
|
|
|
_, err := j.AppendSequence(class, timestamp, value)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *sequencedMemoryJournal) AppendSequence(class string, _ time.Time, value any) (uint64, error) {
|
|
|
|
|
j.mu.Lock()
|
|
|
|
|
defer j.mu.Unlock()
|
|
|
|
|
if j.records == nil {
|
|
|
|
|
j.records = make(map[string][]json.RawMessage)
|
|
|
|
|
}
|
|
|
|
|
if j.sequences == nil {
|
|
|
|
|
j.sequences = make(map[string][]uint64)
|
|
|
|
|
}
|
|
|
|
|
payload, err := json.Marshal(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
sequence := uint64(len(j.sequences[class]) + 1)
|
|
|
|
|
j.records[class] = append(j.records[class], payload)
|
|
|
|
|
j.sequences[class] = append(j.sequences[class], sequence)
|
|
|
|
|
return sequence, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (j *sequencedMemoryJournal) ReadAfter(class string, afterSequence uint64) ([]logservice.SequencedPayload, error) {
|
|
|
|
|
j.mu.Lock()
|
|
|
|
|
defer j.mu.Unlock()
|
|
|
|
|
result := make([]logservice.SequencedPayload, 0)
|
|
|
|
|
for index, sequence := range j.sequences[class] {
|
|
|
|
|
if sequence > afterSequence {
|
|
|
|
|
result = append(result, logservice.SequencedPayload{Sequence: sequence, Payload: append(json.RawMessage(nil), j.records[class][index]...)})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSequencedJournalStartupLoadsOnlyConfiguredRecentEvents(t *testing.T) {
|
|
|
|
|
j := &sequencedMemoryJournal{}
|
|
|
|
|
now := time.Date(2026, 8, 22, 1, 2, 3, 0, time.UTC)
|
|
|
|
|
for sequence := 1; sequence <= 5; sequence++ {
|
|
|
|
|
event := domain.AuditEvent{
|
|
|
|
|
AuditID: fmt.Sprintf("audit_20260822_%06d", sequence),
|
|
|
|
|
Timestamp: now.Add(time.Duration(sequence) * time.Second),
|
|
|
|
|
Actor: "tester",
|
|
|
|
|
Action: "startup.test",
|
|
|
|
|
Target: "journal",
|
|
|
|
|
Result: "succeeded",
|
|
|
|
|
Message: fmt.Sprintf("event-%d", sequence),
|
|
|
|
|
}
|
|
|
|
|
if err := j.Append(logstorage.ClassAudit, event.Timestamp, event); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
j.snapshot.NextSequence = map[string]uint64{logstorage.ClassAudit: 6}
|
|
|
|
|
|
|
|
|
|
store, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{
|
|
|
|
|
LogJournal: j,
|
|
|
|
|
AuditMaxEvents: 2,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
events, err := store.AuditEvents(context.Background())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(events) != 2 || events[0].Message != "event-4" || events[1].Message != "event-5" {
|
|
|
|
|
t.Fatalf("startup audit events = %+v, want only the newest two", events)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSequencedJournalRefreshMergesExternalPortRecordsWithoutDuplicates(t *testing.T) {
|
|
|
|
|
journal := &sequencedMemoryJournal{}
|
|
|
|
|
now := time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)
|
|
|
|
|
store, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := store.RecordPortLog(context.Background(), domain.PortLogEvent{Channel: "CH01", EventType: "traffic", Direction: "TX", Message: "local-1"}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := journal.Append(logstorage.ClassPort, now, domain.PortLogEvent{Timestamp: now, Channel: "CH01", EventType: "traffic", Direction: "RX", Actor: "tera-seriald", Message: "external"}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := store.RecordPortLog(context.Background(), domain.PortLogEvent{Channel: "CH01", EventType: "traffic", Direction: "TX", Message: "local-2"}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
events, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{Channel: "CH01"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(events) != 3 || events[0].Message != "local-1" || events[1].Message != "external" || events[2].Message != "local-2" {
|
|
|
|
|
t.Fatalf("merged port events = %+v", events)
|
|
|
|
|
}
|
|
|
|
|
events, err = store.PortLogEvents(context.Background(), domain.PortLogFilter{Channel: "CH01"})
|
|
|
|
|
if err != nil || len(events) != 3 {
|
|
|
|
|
t.Fatalf("repeated refresh events = %+v, %v", events, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProductionJournalBacksRecordQueryAndRetentionApply(t *testing.T) {
|
|
|
|
|
journal := &memoryJournal{}
|
|
|
|
|
now := time.Date(2026, 7, 29, 1, 0, 0, 0, time.UTC)
|
|
|
|
|
store, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := store.RecordSystemLog(context.Background(), domain.SystemLogEvent{Severity: "info", Source: "test", Message: "durable"}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
reloaded, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
events, err := reloaded.SystemLogEvents(context.Background(), domain.SystemLogFilter{})
|
|
|
|
|
if err != nil || len(events) != 1 || events[0].Message != "durable" {
|
|
|
|
|
t.Fatalf("SystemLogEvents = %+v, %v", events, err)
|
|
|
|
|
}
|
|
|
|
|
days := 7
|
|
|
|
|
if _, err := reloaded.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{SystemDays: &days}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := reloaded.ApplyConfig(context.Background()); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
journal.mu.Lock()
|
|
|
|
|
latest := journal.retentions[len(journal.retentions)-1]
|
|
|
|
|
journal.mu.Unlock()
|
|
|
|
|
if latest.SystemDays != 7 || latest.AuditDays != 365 || latest.PortDays != 365 {
|
|
|
|
|
t.Fatalf("latest retention = %+v", latest)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProductionJournalRejectsPrototypePaths(t *testing.T) {
|
|
|
|
|
_, err := NewStoreWithOptions(time.Now, StoreOptions{LogJournal: &memoryJournal{}, AuditPath: "prototype.jsonl"})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("NewStoreWithOptions accepted split-brain log persistence")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProductionJournalBacksFilteredStreamingExport(t *testing.T) {
|
|
|
|
|
journal := &memoryJournal{}
|
|
|
|
|
store, err := NewStoreWithOptions(time.Now, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, event := range []domain.PortLogEvent{
|
|
|
|
|
{Channel: "CH01", EventType: "command", Command: "show LOGIN", ReturnValue: "ok", Message: "matched"},
|
|
|
|
|
{Channel: "CH02", EventType: "command", Command: "show LOGIN", ReturnValue: "ok", Message: "different channel"},
|
|
|
|
|
} {
|
|
|
|
|
if _, err := store.RecordPortLog(context.Background(), event); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
operation, err := store.StreamLogExport(
|
|
|
|
|
domain.WithActor(context.Background(), domain.Actor{Name: "admin"}),
|
|
|
|
|
domain.LogExportRequest{LogType: "port", Channel: "CH01", Keyword: "login", MaxRows: 10},
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if operation.State != "succeeded" || operation.AuditID == "" {
|
|
|
|
|
t.Fatalf("operation = %+v", operation)
|
|
|
|
|
}
|
|
|
|
|
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
|
|
|
|
if len(lines) != 1 {
|
|
|
|
|
t.Fatalf("export line count = %d, want 1; output: %s", len(lines), output.String())
|
|
|
|
|
}
|
|
|
|
|
var event domain.PortLogEvent
|
|
|
|
|
if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if event.Channel != "CH01" || event.Command != "show LOGIN" {
|
|
|
|
|
t.Fatalf("event = %+v", event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProductionJournalStreamingExportSupportsWebPortFilters(t *testing.T) {
|
|
|
|
|
journal := &memoryJournal{}
|
|
|
|
|
store, err := NewStoreWithOptions(time.Now, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, event := range []domain.PortLogEvent{
|
|
|
|
|
{Channel: "CH25", EventType: "traffic", Direction: "RX", Message: "received"},
|
|
|
|
|
{Channel: "CH25", EventType: "traffic", Direction: "TX", Message: "sent"},
|
|
|
|
|
{Channel: "CH25", EventType: "command", Direction: "RX", Command: "show clock", ReturnValue: "ok", Message: "command"},
|
|
|
|
|
{Channel: "CH01", EventType: "traffic", Direction: "RX", Message: "other channel"},
|
|
|
|
|
} {
|
|
|
|
|
if _, err := store.RecordPortLog(context.Background(), event); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
operation, err := store.StreamLogExport(
|
|
|
|
|
domain.WithActor(context.Background(), domain.Actor{Name: "admin"}),
|
|
|
|
|
domain.LogExportRequest{LogType: "port", Channel: "CH25", EventType: "traffic", Direction: "rx", MaxRows: 10},
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if operation.Result["event_type"] != "traffic" || operation.Result["direction"] != "RX" {
|
|
|
|
|
t.Fatalf("operation filters = %+v", operation.Result)
|
|
|
|
|
}
|
|
|
|
|
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
|
|
|
|
if len(lines) != 1 {
|
|
|
|
|
t.Fatalf("export line count = %d, want 1; output: %s", len(lines), output.String())
|
|
|
|
|
}
|
|
|
|
|
var event domain.PortLogEvent
|
|
|
|
|
if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if event.Channel != "CH25" || event.EventType != "traffic" || event.Direction != "RX" {
|
|
|
|
|
t.Fatalf("exported event = %+v", event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProductionJournalStreamingExportSupportsWebLinkFilters(t *testing.T) {
|
|
|
|
|
journal := &memoryJournal{}
|
|
|
|
|
now := time.Date(2026, 8, 15, 1, 2, 3, 0, time.UTC)
|
|
|
|
|
store, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
for _, observation := range []domain.LinkStateObservation{
|
|
|
|
|
{Kind: "serial", Resource: "CH01", State: "up", Signal: "dcd", ObservedAt: now.Add(-time.Minute)},
|
|
|
|
|
{Kind: "ethernet", Resource: "Interface-1", State: "up", Signal: "carrier", ObservedAt: now.Add(-time.Minute)},
|
|
|
|
|
} {
|
|
|
|
|
if _, err := store.RecordLinkState(context.Background(), observation); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
operation, err := store.StreamLogExport(
|
|
|
|
|
domain.WithActor(context.Background(), domain.Actor{Name: "admin"}),
|
|
|
|
|
domain.LogExportRequest{LogType: "link", Kind: "serial", Resource: "CH01", State: "up", EventType: "snapshot", Keyword: "dcd", MaxRows: 10},
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if operation.Result["kind"] != "serial" || operation.Result["resource"] != "CH01" || operation.Result["state"] != "up" || operation.Result["event_type"] != "snapshot" {
|
|
|
|
|
t.Fatalf("operation filters = %+v", operation.Result)
|
|
|
|
|
}
|
|
|
|
|
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
|
|
|
|
if len(lines) != 1 {
|
|
|
|
|
t.Fatalf("export line count = %d, want 1; output: %s", len(lines), output.String())
|
|
|
|
|
}
|
|
|
|
|
var event domain.LinkLogEvent
|
|
|
|
|
if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if event.Kind != "serial" || event.Resource != "CH01" || event.EventType != "snapshot" {
|
|
|
|
|
t.Fatalf("exported event = %+v", event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProductionJournalReadsCapturedSerialPayloadAndDirectionFilter(t *testing.T) {
|
|
|
|
|
journal := &memoryJournal{}
|
|
|
|
|
now := time.Date(2026, 7, 31, 2, 3, 4, 0, time.UTC)
|
|
|
|
|
want := []byte{0x00, 0xff, 'A', '\r', '\n'}
|
|
|
|
|
if err := journal.Append(logstorage.ClassPort, now, domain.PortLogEvent{
|
|
|
|
|
Timestamp: now, Channel: "CH01", EventType: "traffic", Direction: "RX", Actor: "session-1",
|
|
|
|
|
Message: "RX 5 serial payload byte(s)", Payload: want, PayloadBytes: len(want), PayloadEncoding: "base64",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
store, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{LogJournal: journal})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
events, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{Channel: "CH01", Direction: "RX"})
|
|
|
|
|
if err != nil || len(events) != 1 || !bytes.Equal(events[0].Payload, want) || events[0].PayloadEncoding != "base64" {
|
|
|
|
|
t.Fatalf("captured events = %+v, %v", events, err)
|
|
|
|
|
}
|
|
|
|
|
events, err = store.PortLogEvents(context.Background(), domain.PortLogFilter{Direction: "TX"})
|
|
|
|
|
if err != nil || len(events) != 0 {
|
|
|
|
|
t.Fatalf("TX-filtered events = %+v, %v", events, err)
|
|
|
|
|
}
|
|
|
|
|
}
|