ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
451 lines
14 KiB
Go
451 lines
14 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
)
|
|
|
|
// testDB opens a DB connection, skipping if PG is unavailable.
|
|
func testDB(t *testing.T) *db.DB {
|
|
t.Helper()
|
|
dsn, _, err := db.DSN()
|
|
if err != nil {
|
|
t.Skipf("no database config (%v)", err)
|
|
}
|
|
d, err := db.Open(dsn)
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v)", err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// callInsertAssets calls the insert_assets tool with the given payload.
|
|
func callInsertAssets(t *testing.T, ts *ToolSet, payload any) map[string]any {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(payload)
|
|
tool := ts.insertAssets()
|
|
res, err := tool.Call(context.Background(), raw, nil)
|
|
if err != nil {
|
|
t.Fatalf("insertAssets Call error: %v", err)
|
|
}
|
|
text := res.Flatten()
|
|
var out map[string]any
|
|
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
|
t.Fatalf("unmarshal result: %v\nraw: %s", err, text)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsSubdomainSideEffects
|
|
// 子域名插入 → 自动创建 root_domain + IP 资产,IP 绑定域名
|
|
// =====================================================================
|
|
func TestInsertAssetsSubdomainSideEffects(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE domain IN ('ia-sub.sideeffect-test.com','sideeffect-test.com') OR ip='7.8.9.10'`)
|
|
|
|
out := callInsertAssets(t, ts, map[string]any{
|
|
"assets": []any{
|
|
map[string]any{
|
|
"type": "subdomain",
|
|
"domain": "ia-sub.sideeffect-test.com",
|
|
"record_type": "A",
|
|
"record_value": []string{"7.8.9.10"},
|
|
},
|
|
},
|
|
"task_id": 999,
|
|
})
|
|
|
|
// no errors
|
|
if errs, _ := out["errors"].([]any); len(errs) > 0 {
|
|
t.Errorf("unexpected errors: %v", errs)
|
|
}
|
|
results, _ := out["results"].([]any)
|
|
if len(results) == 0 {
|
|
t.Fatal("no results returned")
|
|
}
|
|
|
|
// root_domain should exist
|
|
var rootCnt int
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type='root_domain' AND domain='sideeffect-test.com'`).Scan(&rootCnt)
|
|
if rootCnt != 1 {
|
|
t.Errorf("side-effect: root_domain not created, got %d", rootCnt)
|
|
}
|
|
|
|
// IP asset should exist with bound_domains containing our subdomain
|
|
var ipID int64
|
|
var boundDomains []byte
|
|
d.QueryRow(`SELECT id, array_to_json(bound_domains)::text FROM assets WHERE type='ip' AND ip='7.8.9.10'`).Scan(&ipID, &boundDomains)
|
|
if ipID == 0 {
|
|
t.Error("side-effect: IP asset not created")
|
|
}
|
|
var domains []string
|
|
json.Unmarshal(boundDomains, &domains)
|
|
found := false
|
|
for _, d := range domains {
|
|
if d == "ia-sub.sideeffect-test.com" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("side-effect: bound_domains should contain subdomain, got %v", domains)
|
|
}
|
|
|
|
// record_value stored as array
|
|
var rvRaw []byte
|
|
d.QueryRow(`SELECT array_to_json(record_value)::text FROM assets WHERE type='subdomain' AND domain='ia-sub.sideeffect-test.com'`).Scan(&rvRaw)
|
|
var rv []string
|
|
json.Unmarshal(rvRaw, &rv)
|
|
if len(rv) == 0 || rv[0] != "7.8.9.10" {
|
|
t.Errorf("record_value stored incorrectly: %v", rv)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsMultiIPSubdomain
|
|
// 多个 IP 的子域名:所有 IP 都应存入 record_value[],各自创建 IP 资产
|
|
// =====================================================================
|
|
func TestInsertAssetsMultiIPSubdomain(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE domain IN ('multi.multiip-test.io','multiip-test.io') OR ip IN ('1.1.1.1','2.2.2.2')`)
|
|
|
|
out := callInsertAssets(t, ts, map[string]any{
|
|
"assets": []any{
|
|
map[string]any{
|
|
"type": "subdomain",
|
|
"domain": "multi.multiip-test.io",
|
|
"record_type": "A",
|
|
"record_value": []string{"1.1.1.1", "2.2.2.2"},
|
|
},
|
|
},
|
|
})
|
|
|
|
if errs, _ := out["errors"].([]any); len(errs) > 0 {
|
|
t.Errorf("unexpected errors: %v", errs)
|
|
}
|
|
|
|
// Both IPs should have IP assets
|
|
var ip1Cnt, ip2Cnt int
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type='ip' AND ip='1.1.1.1'`).Scan(&ip1Cnt)
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type='ip' AND ip='2.2.2.2'`).Scan(&ip2Cnt)
|
|
if ip1Cnt != 1 {
|
|
t.Error("IP 1.1.1.1 asset not created")
|
|
}
|
|
if ip2Cnt != 1 {
|
|
t.Error("IP 2.2.2.2 asset not created")
|
|
}
|
|
|
|
// record_value should contain both IPs
|
|
var rvRaw []byte
|
|
d.QueryRow(`SELECT array_to_json(record_value)::text FROM assets WHERE type='subdomain' AND domain='multi.multiip-test.io'`).Scan(&rvRaw)
|
|
var rv []string
|
|
json.Unmarshal(rvRaw, &rv)
|
|
if len(rv) != 2 {
|
|
t.Errorf("record_value: want 2 IPs, got %v", rv)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsHTTPServiceTechnologies
|
|
// HTTP 服务插入:technologies 存储并可读回;IP 存在时域名和端口写入 IP 资产
|
|
// =====================================================================
|
|
func TestInsertAssetsHTTPServiceTechnologies(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE url='https://tech-test.example.com' OR domain IN ('tech-test.example.com','example.com') OR ip='3.4.5.6'`)
|
|
|
|
out := callInsertAssets(t, ts, map[string]any{
|
|
"assets": []any{
|
|
map[string]any{
|
|
"type": "service",
|
|
"url": "https://tech-test.example.com",
|
|
"service_ip": "3.4.5.6",
|
|
"technologies": []string{"Nginx", "Vue.js", "Cloudflare"},
|
|
"status_code": 200,
|
|
"page_title": "Tech Test Site",
|
|
},
|
|
},
|
|
"task_id": 888,
|
|
})
|
|
|
|
if errs, _ := out["errors"].([]any); len(errs) > 0 {
|
|
t.Errorf("unexpected errors: %v", errs)
|
|
}
|
|
|
|
// technologies should be stored
|
|
var techCnt int
|
|
d.QueryRow(`SELECT array_length(technologies,1) FROM assets WHERE url='https://tech-test.example.com'`).Scan(&techCnt)
|
|
if techCnt != 3 {
|
|
t.Errorf("technologies: want 3, got %d", techCnt)
|
|
}
|
|
|
|
// QueryByType should return technologies correctly (verifies array_to_json scan)
|
|
assets, err := d.Assets().QueryByType("service", 50, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found *db.Asset
|
|
for _, a := range assets {
|
|
if a.URL == "https://tech-test.example.com" {
|
|
found = a
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("service not found via QueryByType")
|
|
}
|
|
if len(found.Technologies) != 3 {
|
|
t.Errorf("QueryByType: technologies roundtrip failed, got %v", found.Technologies)
|
|
}
|
|
|
|
// side effect: IP asset should exist with bound_domains containing the service domain
|
|
var ipID int64
|
|
var bdRaw []byte
|
|
var portCnt int
|
|
d.QueryRow(`SELECT id, array_to_json(bound_domains)::text FROM assets WHERE type='ip' AND ip='3.4.5.6'`).Scan(&ipID, &bdRaw)
|
|
if ipID == 0 {
|
|
t.Error("side-effect: IP asset not created for HTTP service IP")
|
|
}
|
|
var bd []string
|
|
json.Unmarshal(bdRaw, &bd)
|
|
hasDomain := false
|
|
for _, dom := range bd {
|
|
if dom == "tech-test.example.com" {
|
|
hasDomain = true
|
|
}
|
|
}
|
|
if !hasDomain {
|
|
t.Errorf("side-effect: IP bound_domains missing service domain, got %v", bd)
|
|
}
|
|
|
|
// side effect: IP open_ports should contain port 443
|
|
d.QueryRow(`SELECT cardinality(open_ports) FROM assets WHERE type='ip' AND ip='3.4.5.6'`).Scan(&portCnt)
|
|
if portCnt == 0 {
|
|
t.Error("side-effect: IP open_ports not set for HTTP service")
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsOtherService
|
|
// 非 HTTP 服务:c_segment 自动生成,IP 资产含 open_ports 和 bound_domains
|
|
// =====================================================================
|
|
func TestInsertAssetsOtherService(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE
|
|
(type='service' AND ip='10.20.30.40') OR
|
|
(type='ip' AND ip='10.20.30.40') OR
|
|
domain IN ('db.othersvc-test.com','othersvc-test.com')`)
|
|
|
|
out := callInsertAssets(t, ts, map[string]any{
|
|
"assets": []any{
|
|
map[string]any{
|
|
"type": "service",
|
|
"ip": "10.20.30.40",
|
|
"domain": "db.othersvc-test.com",
|
|
"port": 3306,
|
|
"service_name": "mysql",
|
|
},
|
|
},
|
|
})
|
|
|
|
if errs, _ := out["errors"].([]any); len(errs) > 0 {
|
|
t.Errorf("unexpected errors: %v", errs)
|
|
}
|
|
|
|
// c_segment should be auto-set on the service
|
|
var cseg *string
|
|
d.QueryRow(`SELECT c_segment::text FROM assets WHERE type='service' AND ip='10.20.30.40'`).Scan(&cseg)
|
|
if cseg == nil || *cseg != "10.20.30.0/24" {
|
|
t.Errorf("c_segment: want 10.20.30.0/24, got %v", cseg)
|
|
}
|
|
|
|
// IP side-effect: port 3306 in open_ports
|
|
var portCnt int
|
|
d.QueryRow(`SELECT cardinality(open_ports) FROM assets WHERE type='ip' AND ip='10.20.30.40'`).Scan(&portCnt)
|
|
if portCnt == 0 {
|
|
t.Error("side-effect: IP open_ports should contain port 3306")
|
|
}
|
|
|
|
// IP side-effect: bound_domains contains the service domain
|
|
var bdRaw []byte
|
|
d.QueryRow(`SELECT array_to_json(bound_domains)::text FROM assets WHERE type='ip' AND ip='10.20.30.40'`).Scan(&bdRaw)
|
|
var bd []string
|
|
json.Unmarshal(bdRaw, &bd)
|
|
hasDomain := false
|
|
for _, dom := range bd {
|
|
if dom == "db.othersvc-test.com" {
|
|
hasDomain = true
|
|
}
|
|
}
|
|
if !hasDomain {
|
|
t.Errorf("side-effect: IP bound_domains missing service domain, got %v", bd)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsMixedBatch
|
|
// 混合批量插入:一次调用插入多种类型
|
|
// =====================================================================
|
|
func TestInsertAssetsMixedBatch(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE
|
|
domain IN ('batch-sub.batch-test.org','batch-test.org') OR
|
|
ip='55.66.77.88' OR
|
|
url='https://batch-test.org/api' OR
|
|
(type='endpoint' AND url='https://batch-test.org/api/users')`)
|
|
|
|
out := callInsertAssets(t, ts, map[string]any{
|
|
"assets": []any{
|
|
// root_domain
|
|
map[string]any{"type": "root_domain", "domain": "batch-test.org"},
|
|
// subdomain with A record
|
|
map[string]any{"type": "subdomain", "domain": "batch-sub.batch-test.org", "record_type": "A", "record_value": []string{"55.66.77.88"}},
|
|
// HTTP service
|
|
map[string]any{"type": "service", "url": "https://batch-test.org/api", "technologies": []string{"Go", "PostgreSQL"}, "status_code": 200},
|
|
// endpoint
|
|
map[string]any{"type": "endpoint", "url": "https://batch-test.org/api/users", "method": "GET"},
|
|
},
|
|
"task_id": 777,
|
|
})
|
|
|
|
if errs, _ := out["errors"].([]any); len(errs) > 0 {
|
|
t.Errorf("unexpected errors: %v", errs)
|
|
}
|
|
results, _ := out["results"].([]any)
|
|
if len(results) != 4 {
|
|
t.Errorf("mixed batch: want 4 results, got %d", len(results))
|
|
}
|
|
|
|
// verify all types exist in DB
|
|
types := []string{"root_domain", "subdomain", "service", "endpoint"}
|
|
for _, typ := range types {
|
|
var cnt int
|
|
switch typ {
|
|
case "root_domain":
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type=$1 AND domain='batch-test.org'`, typ).Scan(&cnt)
|
|
case "subdomain":
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type=$1 AND domain='batch-sub.batch-test.org'`, typ).Scan(&cnt)
|
|
case "service":
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type=$1 AND url='https://batch-test.org/api'`, typ).Scan(&cnt)
|
|
case "endpoint":
|
|
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type=$1 AND url='https://batch-test.org/api/users'`, typ).Scan(&cnt)
|
|
}
|
|
if cnt != 1 {
|
|
t.Errorf("mixed batch: %s not found in DB", typ)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsDedup
|
|
// 幂等写入:同一资产插入两次,返回相同 ID
|
|
// =====================================================================
|
|
func TestInsertAssetsDedup(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE domain='dedup-ia.deduptest.net' OR domain='deduptest.net'`)
|
|
|
|
payload := map[string]any{
|
|
"assets": []any{
|
|
map[string]any{"type": "root_domain", "domain": "deduptest.net"},
|
|
},
|
|
}
|
|
|
|
out1 := callInsertAssets(t, ts, payload)
|
|
out2 := callInsertAssets(t, ts, payload)
|
|
|
|
getID := func(out map[string]any) float64 {
|
|
results, _ := out["results"].([]any)
|
|
if len(results) == 0 {
|
|
return 0
|
|
}
|
|
m, _ := results[0].(map[string]any)
|
|
id, _ := m["id"].(float64)
|
|
return id
|
|
}
|
|
|
|
id1, id2 := getID(out1), getID(out2)
|
|
if id1 == 0 || id1 != id2 {
|
|
t.Errorf("dedup: want same ID on double insert, got %v vs %v", id1, id2)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TestInsertAssetsRejectsHostnameIPPerItem
|
|
// 一批里混入 ip 填了主机名的一条 → 只有那条失败,其余照常入库,
|
|
// 且错误里带得上 index 和改正方法,Agent 下一轮能自己修好。
|
|
// =====================================================================
|
|
func TestInsertAssetsRejectsHostnameIPPerItem(t *testing.T) {
|
|
d := testDB(t)
|
|
defer d.Close()
|
|
|
|
ts := NewToolSet(nil, "")
|
|
ts.SetAssetStore(d.Assets(), d.Companies())
|
|
defer d.Exec(`DELETE FROM assets WHERE ip IN ('198.51.100.23','cdn.badip-test.com') OR domain='badip-test.com'`)
|
|
|
|
out := callInsertAssets(t, ts, map[string]any{
|
|
"assets": []any{
|
|
map[string]any{"type": "root_domain", "domain": "badip-test.com"},
|
|
map[string]any{"type": "ip", "ip": "cdn.badip-test.com"},
|
|
map[string]any{"type": "ip", "ip": "198.51.100.23"},
|
|
},
|
|
})
|
|
|
|
// The two valid entries must survive the bad one — a whole-batch failure
|
|
// would make the agent re-send assets that were already fine.
|
|
results, _ := out["results"].([]any)
|
|
if len(results) != 2 {
|
|
t.Fatalf("results=%v, want the 2 valid assets", out["results"])
|
|
}
|
|
|
|
errsRaw, _ := out["errors"].([]any)
|
|
if len(errsRaw) != 1 {
|
|
t.Fatalf("errors=%v, want exactly the invalid entry", out["errors"])
|
|
}
|
|
entry, _ := errsRaw[0].(map[string]any)
|
|
if index, _ := entry["index"].(float64); int(index) != 1 {
|
|
t.Fatalf("error index=%v, want 1", entry["index"])
|
|
}
|
|
message, _ := entry["error"].(string)
|
|
for _, want := range []string{"cdn.badip-test.com", "type=subdomain", "A/AAAA"} {
|
|
if !strings.Contains(message, want) {
|
|
t.Fatalf("error message %q lacks %q — agent cannot act on it", message, want)
|
|
}
|
|
}
|
|
|
|
// The rejected value must not have reached the table.
|
|
var stored int
|
|
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE ip='cdn.badip-test.com'`).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored != 0 {
|
|
t.Fatalf("rejected hostname still stored in assets.ip (%d rows)", stored)
|
|
}
|
|
}
|