Netpulse_SasS/server/internal/grpcapi/integration_test.go
zotac 8a92ef8a45 Етап 2: серверна сторона AgentService
Автентифікація зондів за токеном, побудова TaskPlan із детермінованим
schedule_offset, видача розшифрованих креденшелів із TTL, запис телеметрії
в гіпертаблиці, резолвер сусідів LLDP/CDP у topo.links, прийом конфігів.

Ізоляція тенантів робиться двічі — RLS плюс явний предикат tenant_id, бо
RLS не працює на гіпертаблях, а саме туди йде вся телеметрія.

Агент: -token і передача його в метаданих; MarkAllPending() перереєстровує
серії на початку сесії замість обнуляти нумерацію й губити буфер.

Перевірено на Debian 13 / PG 17.11 / TimescaleDB 2.29.1: 11 інтеграційних
тестів проти живої БД (-race), плюс живий прогін справжнього агента проти
справжнього сервера — телеметрія, статус пристрою, heartbeat у базі.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:13:40 +03:00

959 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Наскрізний тест серверної сторони: справжній PostgreSQL/TimescaleDB,
// справжній gRPC, справжня схема з Етапу 1.
//
// Запуск:
//
// NETPULSE_TEST_DSN="postgres://netpulse:netpulse@localhost/netpulse_it" go test ./...
//
// Без змінної тест пропускається — щоб `go test ./...` не падав там,
// де бази немає.
package grpcapi_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/grpcapi"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type fixture struct {
pool *pgxpool.Pool
store *store.Store
ring *crypto.Keyring
client npv1.AgentServiceClient
ctx context.Context
tenantID string
agentID string
deviceID string
ifaceID string
peerID string
peerIfID string
checkID string
token string
}
func setup(t *testing.T) *fixture {
t.Helper()
dsn := os.Getenv("NETPULSE_TEST_DSN")
if dsn == "" {
t.Skip("NETPULSE_TEST_DSN не задано — інтеграційний тест пропущено")
}
ctx := context.Background()
st, err := store.New(ctx, dsn)
if err != nil {
t.Fatalf("підключення до БД: %v", err)
}
t.Cleanup(st.Close)
ring := crypto.NewKeyring()
key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if err := ring.Add("test-key", key); err != nil {
t.Fatalf("Add: %v", err)
}
f := &fixture{pool: st.Pool(), store: st, ring: ring, ctx: ctx}
f.seed(t)
// Сервер із тими самими перехоплювачами, що й у проді.
svc := grpcapi.New(st, ring,
slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})))
lis := bufconn.Listen(1 << 20)
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(svc.UnaryInterceptor),
grpc.ChainStreamInterceptor(svc.StreamInterceptor),
)
npv1.RegisterAgentServiceServer(srv, svc)
go func() { _ = srv.Serve(lis) }()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("клієнт: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
srv.Stop()
_ = lis.Close()
})
f.client = npv1.NewAgentServiceClient(conn)
return f
}
// authCtx додає токен зонда так само, як це робить агент.
func (f *fixture) authCtx() context.Context {
return metadata.AppendToOutgoingContext(f.ctx, "authorization", "Bearer "+f.token)
}
func (f *fixture) seed(t *testing.T) {
t.Helper()
ctx := f.ctx
slug := fmt.Sprintf("it-%d", time.Now().UnixNano())
f.token = "np_test_" + slug
sum := sha256.Sum256([]byte(f.token))
must := func(q string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(ctx, q, args...); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
scan := func(dest *string, q string, args ...any) {
t.Helper()
if err := f.pool.QueryRow(ctx, q, args...).Scan(dest); err != nil {
t.Fatalf("seed %q: %v", q, err)
}
}
// slug має домен core.slug, name — text. Один і той самий $1 для
// обох Postgres вивести не може, тому передаємо окремо.
scan(&f.tenantID, `
INSERT INTO core.tenants (slug, name, status) VALUES ($1, $2, 'active')
RETURNING id::text`, slug, slug)
t.Cleanup(func() {
// Каскади прибирають усе, крім гіпертаблиць — у них немає FK.
_, _ = f.pool.Exec(context.Background(),
`DELETE FROM ts.icmp_samples WHERE tenant_id = $1`, f.tenantID)
_, _ = f.pool.Exec(context.Background(),
`DELETE FROM ts.if_counters WHERE tenant_id = $1`, f.tenantID)
_, _ = f.pool.Exec(context.Background(),
`DELETE FROM ts.device_status_history WHERE tenant_id = $1`, f.tenantID)
_, _ = f.pool.Exec(context.Background(),
`DELETE FROM ts.agent_health WHERE tenant_id = $1`, f.tenantID)
_, _ = f.pool.Exec(context.Background(),
`DELETE FROM ts.samples WHERE series_id IN (SELECT id FROM ts.series WHERE tenant_id = $1)`, f.tenantID)
_, _ = f.pool.Exec(context.Background(),
`DELETE FROM core.tenants WHERE id = $1`, f.tenantID)
})
scan(&f.agentID, `
INSERT INTO core.agents (tenant_id, name, token_hash, status, enabled_modules)
VALUES ($1, 'probe-it', $2, 'online', '{icmp,snmp,topology}')
RETURNING id::text`, f.tenantID, sum[:])
scan(&f.deviceID, `
INSERT INTO inv.devices (tenant_id, agent_id, name, address, kind, vendor, chassis_id, system_name)
VALUES ($1, $2, 'core-sw-01', '10.10.0.1', 'switch', 'cisco', '0011.2233.4455', 'core-sw-01')
RETURNING id::text`, f.tenantID, f.agentID)
// Сусід, якого автовиявлення має знайти.
scan(&f.peerID, `
INSERT INTO inv.devices (tenant_id, agent_id, name, address, kind, chassis_id, system_name)
VALUES ($1, $2, 'edge-rtr-01', '10.10.0.2', 'router', '0011.2233.6677', 'edge-rtr-01')
RETURNING id::text`, f.tenantID, f.agentID)
scan(&f.ifaceID, `
INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name, speed_bps, oper_status)
VALUES ($1, $2, 1, 'GigabitEthernet0/1', 1000000000, 'up')
RETURNING id::text`, f.tenantID, f.deviceID)
scan(&f.peerIfID, `
INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name, speed_bps, oper_status)
VALUES ($1, $2, 1, 'ether1', 1000000000, 'up')
RETURNING id::text`, f.tenantID, f.peerID)
scan(&f.checkID, `
INSERT INTO core.checks (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries)
VALUES ($1, $2, 'icmp.ping', '{"count":3}'::jsonb, 60, 3000, 2)
RETURNING id::text`, f.tenantID, f.deviceID)
// Креденшел зі справжнім шифруванням: тест має пройти той самий
// шлях, що й прод, включно з AAD.
aad := f.tenantID + "|snmp_v2c|" + f.deviceID
sec, err := f.ring.Encrypt([]byte("public-it"), aad)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
var secretID string
scan(&secretID, `
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
VALUES ($1, 'snmp_v3', $2, $3, $4, $5, $6)
RETURNING id::text`,
f.tenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad)
var credID string
scan(&credID, `
INSERT INTO inv.credentials (tenant_id, name, proto, username, port, secret_id)
VALUES ($1, 'snmp-ro', 'snmp_v2c', '', 161, $2)
RETURNING id::text`, f.tenantID, secretID)
must(`INSERT INTO inv.device_credentials (device_id, credential_id, priority)
VALUES ($1, $2, 10)`, f.deviceID, credID)
}
// ---------------------------------------------------------------------
// 1. Рукостискання, план задач, креденшели
// ---------------------------------------------------------------------
func TestControlHandshake(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
stream, err := f.client.Control(ctx)
if err != nil {
t.Fatalf("Control: %v", err)
}
if err := stream.Send(&npv1.ControlUp{
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
AgentId: f.agentID,
Hostname: "probe-it",
Build: &npv1.AgentBuild{Version: "test", Os: "linux", Arch: "amd64"},
}},
}); err != nil {
t.Fatalf("Hello: %v", err)
}
down, err := stream.Recv()
if err != nil {
t.Fatalf("Welcome: %v", err)
}
w := down.GetWelcome()
if w == nil {
t.Fatalf("замість Welcome прийшло %T", down.Payload)
}
if !w.TaskPlanFollows {
t.Fatal("сервер не збирається слати план, хоча агент прийшов без хеша")
}
if w.TelemetryMaxBatchSize == 0 || w.MaxConcurrentChecks == 0 {
t.Fatalf("ліміти не заповнені: %+v", w)
}
var (
plan *npv1.TaskPlan
creds *npv1.CredentialBundle
mods *npv1.ModuleControl
)
deadline := time.Now().Add(10 * time.Second)
for (plan == nil || creds == nil || mods == nil) && time.Now().Before(deadline) {
msg, err := stream.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
switch p := msg.Payload.(type) {
case *npv1.ControlDown_TaskPlan:
plan = p.TaskPlan
case *npv1.ControlDown_Credentials:
creds = p.Credentials
case *npv1.ControlDown_ModuleControl:
mods = p.ModuleControl
}
}
if plan == nil {
t.Fatal("план задач не надійшов")
}
if len(plan.Tasks) != 1 || plan.Tasks[0].CheckId != f.checkID {
t.Fatalf("план зібрано неправильно: %+v", plan.Tasks)
}
task := plan.Tasks[0]
if task.CheckType != "icmp.ping" {
t.Fatalf("check_type = %q", task.CheckType)
}
if task.Interval.AsDuration() != time.Minute {
t.Fatalf("interval = %v", task.Interval.AsDuration())
}
// Offset має бути в межах інтервалу — інакше задача або стартує
// одразу разом з усіма, або з'їде в наступний цикл.
off := task.ScheduleOffset.AsDuration()
if off < 0 || off >= time.Minute {
t.Fatalf("schedule_offset поза інтервалом: %v", off)
}
// І він має бути детермінованим: та сама функція на будь-якому вузлі.
if want := store.ScheduleOffset(f.checkID, time.Minute); off != want {
t.Fatalf("offset не детермінований: %v != %v", off, want)
}
if len(plan.Devices) != 1 || plan.Devices[0].Address != "10.10.0.1" {
t.Fatalf("пристрої в плані: %+v", plan.Devices)
}
if len(plan.PlanHash) == 0 {
t.Fatal("план без хеша — агент не зможе уникнути перезаливки")
}
if mods == nil || len(mods.Modules) == 0 {
t.Fatal("сервер не сказав, які модулі вмикати")
}
if creds == nil {
t.Fatal("креденшели не надійшли")
}
list := creds.ByDevice[f.deviceID]
if list == nil || len(list.Credentials) != 1 {
t.Fatalf("креденшели пристрою: %+v", creds.ByDevice)
}
// Найважливіше: секрет розшифрувався тим самим ключем і AAD.
if got := list.Credentials[0].GetCommunity(); got != "public-it" {
t.Fatalf("community = %q, очікували public-it", got)
}
if creds.ExpiresAt == nil || creds.ExpiresAt.AsTime().Before(time.Now()) {
t.Fatal("креденшели без дійсного TTL")
}
// Зонд позначений онлайн у БД.
var status string
if err := f.pool.QueryRow(f.ctx,
`SELECT status::text FROM core.agents WHERE id = $1`, f.agentID).Scan(&status); err != nil {
t.Fatalf("статус зонда: %v", err)
}
if status != "online" {
t.Fatalf("статус зонда = %q", status)
}
}
// Токен визначає зонда. Назватись чужим id не можна навіть із валідним
// токеном — інакше один зонд отримав би план іншого.
func TestControlRejectsMismatchedAgentID(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 10*time.Second)
defer cancel()
stream, err := f.client.Control(ctx)
if err != nil {
t.Fatalf("Control: %v", err)
}
if err := stream.Send(&npv1.ControlUp{
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
AgentId: "00000000-0000-0000-0000-0000000000ff",
}},
}); err != nil {
t.Fatalf("Hello: %v", err)
}
if _, err := stream.Recv(); err == nil {
t.Fatal("сервер прийняв чужий agent_id")
}
}
func TestUnauthenticatedRejected(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.ctx, 10*time.Second)
defer cancel()
stream, err := f.client.Control(ctx)
if err != nil {
return // клієнт міг відмовити одразу
}
_ = stream.Send(&npv1.ControlUp{Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{}}})
if _, err := stream.Recv(); err == nil {
t.Fatal("сервер пустив сесію без токена")
}
}
// ---------------------------------------------------------------------
// 2. Телеметрія доходить до гіпертаблиць
// ---------------------------------------------------------------------
func TestTelemetryPersisted(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
stream, err := f.client.StreamTelemetry(ctx)
if err != nil {
t.Fatalf("StreamTelemetry: %v", err)
}
now := time.Now().Truncate(time.Millisecond)
batch := &npv1.TelemetryBatch{
BatchId: 1,
AgentId: f.agentID,
CreatedAt: timestamppb.New(now),
NewSeries: []*npv1.SeriesDescriptor{{
SeriesRef: 1,
DeviceId: f.deviceID,
PluginKey: "snmp",
MetricKey: "cpu.util",
Unit: "pct",
Labels: map[string]string{"core": "0"},
}},
Samples: []*npv1.MetricSample{
{SeriesRef: 1, Ts: timestamppb.New(now), Value: 37.5},
},
Icmp: []*npv1.IcmpResult{{
DeviceId: f.deviceID, CheckId: f.checkID, Ts: timestamppb.New(now),
RttAvgMs: 1.25, RttMinMs: 1.1, RttMaxMs: 1.5, JitterMs: 0.2,
LossPct: 0, PacketsSent: 3, PacketsRecv: 3, Reachable: true,
}},
Interfaces: []*npv1.InterfaceCounters{{
DeviceId: f.deviceID, InterfaceId: f.ifaceID, Ts: timestamppb.New(now),
InOctets: 1 << 30, OutOctets: 1 << 29,
InBps: 420e6, OutBps: 780e6,
UtilInPct: 42, UtilOutPct: 78, OperUp: true,
Interval: durationpb.New(time.Minute),
}},
}
if err := stream.Send(batch); err != nil {
t.Fatalf("Send: %v", err)
}
ack, err := stream.Recv()
if err != nil {
t.Fatalf("Recv ack: %v", err)
}
if ack.AckedThroughBatchId != 1 {
t.Fatalf("acked = %d, помилка: %+v", ack.AckedThroughBatchId, ack.Error)
}
// Семпл у ts.samples із розв'язаним series_id.
var value float64
var metricKey, unit string
if err := f.pool.QueryRow(f.ctx, `
SELECT s.value, se.metric_key, se.unit
FROM ts.samples s JOIN ts.series se ON se.id = s.series_id
WHERE se.tenant_id = $1 AND se.metric_key = 'cpu.util'
ORDER BY s.ts DESC LIMIT 1`, f.tenantID).Scan(&value, &metricKey, &unit); err != nil {
t.Fatalf("семпл не записався: %v", err)
}
if value != 37.5 || unit != "pct" {
t.Fatalf("семпл спотворений: value=%v unit=%q", value, unit)
}
// ICMP у широкій таблиці — те, що читає мапа.
var rtt float32
var reachable bool
if err := f.pool.QueryRow(f.ctx, `
SELECT rtt_avg_ms, reachable FROM ts.icmp_samples
WHERE device_id = $1 ORDER BY ts DESC LIMIT 1`, f.deviceID).Scan(&rtt, &reachable); err != nil {
t.Fatalf("ICMP не записався: %v", err)
}
if !reachable || rtt < 1.2 || rtt > 1.3 {
t.Fatalf("ICMP спотворений: rtt=%v reachable=%v", rtt, reachable)
}
// Завантаження інтерфейсу — джерело швидкості анімації на мапі.
var utilOut float32
if err := f.pool.QueryRow(f.ctx, `
SELECT util_out_pct FROM ts.if_counters
WHERE interface_id = $1 ORDER BY ts DESC LIMIT 1`, f.ifaceID).Scan(&utilOut); err != nil {
t.Fatalf("лічильники не записались: %v", err)
}
if utilOut != 78 {
t.Fatalf("util_out_pct = %v", utilOut)
}
// Статус пристрою піднявся з ICMP і лишив слід в історії.
var devStatus string
if err := f.pool.QueryRow(f.ctx,
`SELECT status::text FROM inv.devices WHERE id = $1`, f.deviceID).Scan(&devStatus); err != nil {
t.Fatalf("статус пристрою: %v", err)
}
if devStatus != "up" {
t.Fatalf("статус пристрою = %q, очікували up", devStatus)
}
var transitions int
if err := f.pool.QueryRow(f.ctx, `
SELECT count(*) FROM ts.device_status_history
WHERE device_id = $1 AND status = 'up'`, f.deviceID).Scan(&transitions); err != nil {
t.Fatalf("історія станів: %v", err)
}
if transitions != 1 {
t.Fatalf("переходів в історії: %d, очікували 1", transitions)
}
// Повтор того самого батчу (at-least-once після реконекту) не має
// ані падати, ані дублювати рядки.
batch.IsRetransmit = true
batch.BatchId = 2
batch.NewSeries = nil
if err := stream.Send(batch); err != nil {
t.Fatalf("повторний Send: %v", err)
}
if _, err := stream.Recv(); err != nil {
t.Fatalf("повторний ack: %v", err)
}
var icmpRows int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*) FROM ts.icmp_samples WHERE device_id = $1`, f.deviceID).Scan(&icmpRows); err != nil {
t.Fatalf("підрахунок: %v", err)
}
if icmpRows != 1 {
t.Fatalf("повтор батчу продублював рядки: %d", icmpRows)
}
// І жодного зайвого переходу в історії від повтору.
if err := f.pool.QueryRow(f.ctx, `
SELECT count(*) FROM ts.device_status_history WHERE device_id = $1`,
f.deviceID).Scan(&transitions); err != nil {
t.Fatalf("історія: %v", err)
}
if transitions != 1 {
t.Fatalf("повтор додав перехід в історію: %d", transitions)
}
}
// Невідомий series_ref → чесний запит на перереєстрацію.
func TestTelemetryUnknownSeriesRef(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
stream, err := f.client.StreamTelemetry(ctx)
if err != nil {
t.Fatalf("StreamTelemetry: %v", err)
}
if err := stream.Send(&npv1.TelemetryBatch{
BatchId: 5, AgentId: f.agentID,
Samples: []*npv1.MetricSample{{SeriesRef: 404, Ts: timestamppb.Now(), Value: 1}},
}); err != nil {
t.Fatalf("Send: %v", err)
}
ack, err := stream.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if !ack.ResetSeriesTable {
t.Fatal("сервер проковтнув невідомий series_ref")
}
if ack.Error == nil || ack.Error.Code != "unknown_series_ref" {
t.Fatalf("немає діагностики: %+v", ack.Error)
}
}
// ---------------------------------------------------------------------
// 3. Автовиявлення зводить сусідів у лінк
// ---------------------------------------------------------------------
func TestDiscoveryResolvesLink(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
ack, err := f.client.ReportDiscovery(ctx, &npv1.DiscoveryReport{
AgentId: f.agentID,
Final: true,
Neighbors: []*npv1.NeighborRecord{{
DeviceId: f.deviceID,
LocalInterfaceId: f.ifaceID,
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
RemoteChassisId: "0011.2233.6677",
RemoteSystemName: "edge-rtr-01",
RemotePortId: "ether1",
RemoteCapabilities: []string{"bridge", "router"},
SeenAt: timestamppb.Now(),
}},
})
if err != nil {
t.Fatalf("ReportDiscovery: %v", err)
}
if !ack.Accepted {
t.Fatalf("звіт відхилено: %+v", ack.Error)
}
if ack.NeighborsResolved != 1 {
t.Fatalf("зіставлено сусідів: %d", ack.NeighborsResolved)
}
if ack.LinksCreated != 1 {
t.Fatalf("створено лінків: %d", ack.LinksCreated)
}
// Сусід зберігся з резолвленими посиланнями й високою впевненістю:
// збіг за chassis-id надійніший за збіг за іменем.
var resolvedDev, resolvedIf string
var confidence int
if err := f.pool.QueryRow(f.ctx, `
SELECT resolved_device_id::text, resolved_interface_id::text, confidence
FROM topo.neighbors WHERE device_id = $1`, f.deviceID).
Scan(&resolvedDev, &resolvedIf, &confidence); err != nil {
t.Fatalf("сусід не записався: %v", err)
}
if resolvedDev != f.peerID {
t.Fatalf("сусід зіставлений не з тим пристроєм: %s", resolvedDev)
}
if resolvedIf != f.peerIfID {
t.Fatalf("порт сусіда не зіставлений: %s", resolvedIf)
}
if confidence < 90 {
t.Fatalf("впевненість збігу за chassis-id = %d, очікували >= 90", confidence)
}
// Лінк створився з портами й пропускною здатністю з інтерфейсу.
var capacity int64
var discoveredBy string
if err := f.pool.QueryRow(f.ctx, `
SELECT COALESCE(capacity_bps, 0), discovered_by::text
FROM topo.links WHERE tenant_id = $1`, f.tenantID).Scan(&capacity, &discoveredBy); err != nil {
t.Fatalf("лінк не створився: %v", err)
}
if capacity != 1000000000 {
t.Fatalf("capacity_bps = %d — анімація трафіку не матиме знаменника", capacity)
}
if discoveredBy != "lldp" {
t.Fatalf("discovered_by = %q", discoveredBy)
}
// Повторний звіт не має плодити другий лінк — навіть якщо сусід
// прийшов з іншого боку (B→A).
if _, err := f.client.ReportDiscovery(ctx, &npv1.DiscoveryReport{
AgentId: f.agentID, Final: true,
Neighbors: []*npv1.NeighborRecord{{
DeviceId: f.peerID,
LocalInterfaceId: f.peerIfID,
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
RemoteChassisId: "0011.2233.4455",
RemotePortId: "GigabitEthernet0/1",
SeenAt: timestamppb.Now(),
}},
}); err != nil {
t.Fatalf("повторний звіт: %v", err)
}
var links int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*) FROM topo.links WHERE tenant_id = $1`, f.tenantID).Scan(&links); err != nil {
t.Fatalf("підрахунок лінків: %v", err)
}
if links != 1 {
t.Fatalf("зустрічний звіт створив дублікат: лінків %d", links)
}
}
// Підтверджений людиною лінк не має перезаписуватись автовиявленням.
func TestDiscoveryRespectsPinnedLink(t *testing.T) {
f := setup(t)
if _, err := f.pool.Exec(f.ctx, `
INSERT INTO topo.links
(tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id,
kind, discovered_by, confidence, is_pinned)
VALUES ($1,$2,$3,$4,$5,'physical','manual',100,true)
`, f.tenantID, f.deviceID, f.ifaceID, f.peerID, f.peerIfID); err != nil {
t.Fatalf("seed лінка: %v", err)
}
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
if _, err := f.client.ReportDiscovery(ctx, &npv1.DiscoveryReport{
AgentId: f.agentID, Final: true,
Neighbors: []*npv1.NeighborRecord{{
DeviceId: f.deviceID,
LocalInterfaceId: f.ifaceID,
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_CDP,
RemoteChassisId: "0011.2233.6677",
RemotePortId: "ether1",
SeenAt: timestamppb.Now(),
}},
}); err != nil {
t.Fatalf("ReportDiscovery: %v", err)
}
var discoveredBy string
var pinned bool
if err := f.pool.QueryRow(f.ctx, `
SELECT discovered_by::text, is_pinned FROM topo.links WHERE tenant_id = $1
`, f.tenantID).Scan(&discoveredBy, &pinned); err != nil {
t.Fatalf("лінк: %v", err)
}
if !pinned || discoveredBy != "manual" {
t.Fatalf("автовиявлення затерло ручний лінк: discovered_by=%q pinned=%v",
discoveredBy, pinned)
}
}
// ---------------------------------------------------------------------
// 4. NCM: збір конфігу та дедуплікація
// ---------------------------------------------------------------------
func TestConfigUploadAndDedup(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
body := []byte("!\nhostname core-sw-01\ninterface Gi0/1\n description uplink\n!\n")
sum := sha256.Sum256(body)
upload := func(jobID string) *npv1.ConfigReceipt {
t.Helper()
stream, err := f.client.UploadConfig(ctx)
if err != nil {
t.Fatalf("UploadConfig: %v", err)
}
if err := stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Header{
Header: &npv1.ConfigHeader{
JobId: jobID, AgentId: f.agentID, DeviceId: f.deviceID,
ConfigType: "running", CollectedAt: timestamppb.Now(), Encoding: "none",
}}}); err != nil {
t.Fatalf("header: %v", err)
}
// Два чанки, щоб перевірити склеювання.
mid := len(body) / 2
for i, part := range [][]byte{body[:mid], body[mid:]} {
if err := stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Chunk{
Chunk: &npv1.ConfigChunk{Sequence: uint32(i), Data: part},
}}); err != nil {
t.Fatalf("chunk %d: %v", i, err)
}
}
if err := stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Trailer{
Trailer: &npv1.ConfigTrailer{
Success: true, ContentSha256: sum[:],
SizeBytes: uint64(len(body)), ChunkCount: 2,
}}}); err != nil {
t.Fatalf("trailer: %v", err)
}
receipt, err := stream.CloseAndRecv()
if err != nil {
t.Fatalf("CloseAndRecv: %v", err)
}
return receipt
}
first := upload("job-1")
if !first.Accepted || first.Unchanged {
t.Fatalf("перший конфіг: accepted=%v unchanged=%v err=%+v",
first.Accepted, first.Unchanged, first.Error)
}
if first.ConfigId == "" {
t.Fatal("немає config_id")
}
// Другий збір того самого конфігу — не нова версія.
second := upload("job-2")
if !second.Accepted {
t.Fatalf("другий конфіг відхилено: %+v", second.Error)
}
if !second.Unchanged {
t.Fatal("незмінений конфіг створив нову версію — історія засмітиться")
}
var versions int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*) FROM ncm.configs WHERE device_id = $1`, f.deviceID).Scan(&versions); err != nil {
t.Fatalf("підрахунок версій: %v", err)
}
if versions != 1 {
t.Fatalf("версій у ncm.configs: %d, очікували 1", versions)
}
// Тіло збережене зашифрованим і читається назад тим самим ключем.
var keyID, aad string
var nonce, ct, tag []byte
if err := f.pool.QueryRow(f.ctx, `
SELECT s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM ncm.configs c JOIN core.secrets s ON s.id = c.body_secret_id
WHERE c.device_id = $1`, f.deviceID).Scan(&keyID, &nonce, &ct, &tag, &aad); err != nil {
t.Fatalf("зашифроване тіло: %v", err)
}
plain, err := f.ring.Decrypt(&crypto.Secret{
KeyID: keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
}, aad)
if err != nil {
t.Fatalf("розшифрування тіла: %v", err)
}
if string(plain) != string(body) {
t.Fatal("тіло конфігу спотворилось при збереженні")
}
// І воно точно не лежить відкритим текстом.
if string(ct) == string(body) {
t.Fatal("конфіг збережено без шифрування")
}
}
func TestConfigUploadRejectsBadChecksum(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
stream, err := f.client.UploadConfig(ctx)
if err != nil {
t.Fatalf("UploadConfig: %v", err)
}
body := []byte("hostname core-sw-01\n")
bogus := sha256.Sum256([]byte("щось інше"))
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Header{
Header: &npv1.ConfigHeader{JobId: "job-x", DeviceId: f.deviceID, ConfigType: "running"}}})
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Chunk{
Chunk: &npv1.ConfigChunk{Sequence: 0, Data: body}}})
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Trailer{
Trailer: &npv1.ConfigTrailer{Success: true, ContentSha256: bogus[:],
SizeBytes: uint64(len(body)), ChunkCount: 1}}})
receipt, err := stream.CloseAndRecv()
if err != nil {
t.Fatalf("CloseAndRecv: %v", err)
}
if receipt.Accepted {
t.Fatal("прийнято конфіг із розбіжною сумою")
}
if receipt.Error == nil || receipt.Error.Code != "checksum_mismatch" {
t.Fatalf("немає причини відмови: %+v", receipt.Error)
}
var versions int
_ = f.pool.QueryRow(f.ctx,
`SELECT count(*) FROM ncm.configs WHERE device_id = $1`, f.deviceID).Scan(&versions)
if versions != 0 {
t.Fatalf("зіпсований конфіг усе одно збережено: версій %d", versions)
}
}
// ---------------------------------------------------------------------
// 5. Heartbeat лягає в самометрики
// ---------------------------------------------------------------------
func TestHeartbeatPersisted(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
stream, err := f.client.Control(ctx)
if err != nil {
t.Fatalf("Control: %v", err)
}
if err := stream.Send(&npv1.ControlUp{
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{AgentId: f.agentID}},
}); err != nil {
t.Fatalf("Hello: %v", err)
}
if _, err := stream.Recv(); err != nil {
t.Fatalf("Welcome: %v", err)
}
if err := stream.Send(&npv1.ControlUp{
Payload: &npv1.ControlUp_Heartbeat{Heartbeat: &npv1.Heartbeat{
Ts: timestamppb.Now(),
Health: &npv1.AgentHealth{
CpuPct: 2.5, RssBytes: 12 << 20, Goroutines: 48,
QueueDepth: 3, DroppedSamples: 7,
},
TasksRunning: 1,
}},
}); err != nil {
t.Fatalf("Heartbeat: %v", err)
}
// Дочекаємось Ping у відповідь — він приходить після запису.
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
msg, err := stream.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if msg.GetPing() != nil {
break
}
}
var rss int64
if err := f.pool.QueryRow(f.ctx, `
SELECT rss_bytes FROM ts.agent_health WHERE agent_id = $1
ORDER BY ts DESC LIMIT 1`, f.agentID).Scan(&rss); err != nil {
t.Fatalf("самометрики не записались: %v", err)
}
if rss != 12<<20 {
t.Fatalf("rss_bytes = %d", rss)
}
// Втрачені семпли мають бути видимі оператору у зведенні зонда,
// а не тільки в графіку.
var health []byte
if err := f.pool.QueryRow(f.ctx,
`SELECT health::text FROM core.agents WHERE id = $1`, f.agentID).Scan(&health); err != nil {
t.Fatalf("зведення: %v", err)
}
var h map[string]any
if err := json.Unmarshal(health, &h); err != nil {
t.Fatalf("health не JSON: %v", err)
}
if h["dropped_samples"] == nil || fmt.Sprint(h["dropped_samples"]) != "7" {
t.Fatalf("dropped_samples не потрапило у зведення: %v", h)
}
}
// ---------------------------------------------------------------------
// 6. Хеш плану дозволяє не переливати задачі після реконекту
// ---------------------------------------------------------------------
func TestPlanHashSkipsResend(t *testing.T) {
f := setup(t)
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
defer cancel()
// Перше підключення: дізнаємось хеш.
plan, err := f.store.BuildPlan(f.ctx, &store.Agent{ID: f.agentID, TenantID: f.tenantID})
if err != nil {
t.Fatalf("BuildPlan: %v", err)
}
if len(plan.PlanHash) == 0 {
t.Fatal("план без хеша")
}
stream, err := f.client.Control(ctx)
if err != nil {
t.Fatalf("Control: %v", err)
}
if err := stream.Send(&npv1.ControlUp{
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
AgentId: f.agentID, TaskPlanHash: plan.PlanHash,
}},
}); err != nil {
t.Fatalf("Hello: %v", err)
}
down, err := stream.Recv()
if err != nil {
t.Fatalf("Welcome: %v", err)
}
if down.GetWelcome().GetTaskPlanFollows() {
t.Fatal("сервер збирається слати план, хоча хеш збігся")
}
// Хеш детермінований: та сама база — той самий хеш.
again, err := f.store.BuildPlan(f.ctx, &store.Agent{ID: f.agentID, TenantID: f.tenantID})
if err != nil {
t.Fatalf("BuildPlan: %v", err)
}
if hex.EncodeToString(again.PlanHash) != hex.EncodeToString(plan.PlanHash) {
t.Fatal("хеш плану не детермінований")
}
}