Files
artex/db/assets_test.go
dela 0335d572de
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
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

1127 lines
31 KiB
Go

package db
import (
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
)
// testSetup opens a DB and returns both stores. Skips if no PG.
func testSetup(t *testing.T) (*DB, *AssetStore, *CompanyStore) {
t.Helper()
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
return d, d.Assets(), d.Companies()
}
// deleteAsset removes one v2 asset by id.
func deleteAsset(d *DB, id int64) {
d.Exec(`DELETE FROM assets WHERE id = $1`, id)
}
// =====================================================================
// RootDomain
// =====================================================================
func TestUpsertRootDomain(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id1, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "roottest.io", ICP: "A12345", TaskID: 1})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id1)
if id1 == 0 {
t.Fatal("expected non-zero id")
}
// upsert again — same id (dedup), ICP preserved
id2, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "roottest.io", TaskID: 2})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("dedup failed: %d != %d", id2, id1)
}
// task_ids should now contain both 1 and 2
var taskIDs []byte
d.QueryRow(`SELECT task_ids FROM assets WHERE id = $1`, id1).Scan(&taskIDs)
// ICP should still be set (COALESCE keeps existing)
var icp *string
d.QueryRow(`SELECT icp FROM assets WHERE id = $1`, id1).Scan(&icp)
if icp == nil || *icp != "A12345" {
t.Errorf("ICP not preserved: %v", icp)
}
}
func TestUpsertRootDomainEmpty(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
_, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: ""})
if err == nil {
t.Error("expected error for empty domain")
}
}
// =====================================================================
// IP
// =====================================================================
func TestUpsertIP(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id1, err := av2.UpsertIP(UpsertIPReq{
IP: "192.168.10.5",
OpenPorts: []PortService{{Port: 22, Service: "ssh"}, {Port: 80, Service: "http"}},
TaskID: 1,
})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id1)
// c_segment should be 192.168.10.0/24
var cseg *string
d.QueryRow(`SELECT c_segment::text FROM assets WHERE id = $1`, id1).Scan(&cseg)
if cseg == nil || *cseg != "192.168.10.0/24" {
t.Errorf("c_segment: want 192.168.10.0/24, got %v", cseg)
}
// append another port via UpsertIP (merge)
id2, err := av2.UpsertIP(UpsertIPReq{
IP: "192.168.10.5",
OpenPorts: []PortService{{Port: 443, Service: "https"}},
TaskID: 2,
})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("dedup failed: %d != %d", id2, id1)
}
// verify all 3 ports present
var cnt int
d.QueryRow(`SELECT cardinality(open_ports) FROM assets WHERE id = $1`, id1).Scan(&cnt)
if cnt != 3 {
t.Errorf("open_ports merge: want 3 ports, got %d", cnt)
}
}
func TestUpsertIPBoundDomains(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id, err := av2.UpsertIP(UpsertIPReq{
IP: "10.1.2.3",
BoundDomains: []string{"a.example.com"},
})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
// append a second domain
id2, err := av2.UpsertIP(UpsertIPReq{
IP: "10.1.2.3",
BoundDomains: []string{"b.example.com"},
})
if err != nil {
t.Fatal(err)
}
if id2 != id {
t.Errorf("dedup failed: %d != %d", id2, id)
}
var cnt int
d.QueryRow(`SELECT array_length(bound_domains, 1) FROM assets WHERE id = $1`, id).Scan(&cnt)
if cnt != 2 {
t.Errorf("bound_domains merge: want 2, got %d", cnt)
}
}
func TestAppendIPPort(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id, err := av2.UpsertIP(UpsertIPReq{IP: "10.9.8.7"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
if err := av2.AppendIPPort("10.9.8.7", 3306, "mysql"); err != nil {
t.Fatal(err)
}
if err := av2.AppendIPPort("10.9.8.7", 22, "ssh"); err != nil {
t.Fatal(err)
}
var cnt int
d.QueryRow(`SELECT cardinality(open_ports) FROM assets WHERE id = $1`, id).Scan(&cnt)
if cnt != 2 {
t.Errorf("AppendIPPort: want 2 ports, got %d", cnt)
}
}
// =====================================================================
// Subdomain
// =====================================================================
func TestUpsertSubdomain(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id, err := av2.UpsertSubdomain(UpsertSubdomainReq{
Domain: "sub.subdtest.com",
RecordType: "A",
RecordValue: []string{"1.2.3.4"},
ICP: "B99",
TaskID: 1,
})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE domain IN ('sub.subdtest.com', 'subdtest.com') OR ip = '1.2.3.4'`)
if id == 0 {
t.Fatal("expected non-zero id")
}
// verify root_domain auto-populated
var rootDomain string
d.QueryRow(`SELECT COALESCE(root_domain,'') FROM assets WHERE id = $1`, id).Scan(&rootDomain)
if rootDomain != "subdtest.com" {
t.Errorf("root_domain: want subdtest.com, got %q", rootDomain)
}
// side effect: root domain asset should exist
var rootCnt int
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type = 'root_domain' AND domain = 'subdtest.com'`).Scan(&rootCnt)
if rootCnt != 1 {
t.Error("side-effect root domain not created")
}
// side effect: IP asset with bound_domain
var ipCnt int
d.QueryRow(`SELECT COUNT(*) FROM assets WHERE type = 'ip' AND ip = '1.2.3.4'`).Scan(&ipCnt)
if ipCnt != 1 {
t.Error("side-effect IP asset not created")
}
}
func TestUpsertSubdomainDedup(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id1, err := av2.UpsertSubdomain(UpsertSubdomainReq{Domain: "dedup.subdtest2.net", RecordType: "A", RecordValue: []string{"5.5.5.5"}})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE domain IN ('dedup.subdtest2.net', 'subdtest2.net') OR ip = '5.5.5.5'`)
id2, err := av2.UpsertSubdomain(UpsertSubdomainReq{Domain: "dedup.subdtest2.net", RecordType: "A", RecordValue: []string{"5.5.5.5"}})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("subdomain dedup failed: %d vs %d", id2, id1)
}
}
// =====================================================================
// App
// =====================================================================
func TestUpsertAppByBundle(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id1, err := av2.UpsertApp(UpsertAppReq{Name: "MyApp", BundleID: "com.example.myapp", Category: "tools"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id1)
// dedup by bundle
id2, err := av2.UpsertApp(UpsertAppReq{Name: "MyApp Updated", BundleID: "com.example.myapp"})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("app bundle dedup failed: %d vs %d", id2, id1)
}
}
func TestUpsertAppByName(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id1, err := av2.UpsertApp(UpsertAppReq{Name: "UniqueAppNoBundle", Category: "utility"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id1)
id2, err := av2.UpsertApp(UpsertAppReq{Name: "UniqueAppNoBundle"})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("app name dedup failed: %d vs %d", id2, id1)
}
}
// =====================================================================
// HTTPService
// =====================================================================
func TestUpsertHTTPService(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
sc := 200
cl := int64(1024)
id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{
URL: "https://www.httptest.example.com/",
Technologies: []string{"nginx", "vue"},
StatusCode: &sc,
ContentLength: &cl,
PageTitle: "Test Site",
TaskID: 1,
})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE url = 'https://www.httptest.example.com' OR domain = 'www.httptest.example.com' OR domain = 'httptest.example.com'`)
if id1 == 0 {
t.Fatal("expected non-zero id")
}
// verify technology was stored
var techCnt int
d.QueryRow(`SELECT array_length(technologies, 1) FROM assets WHERE id = $1`, id1).Scan(&techCnt)
if techCnt != 2 {
t.Errorf("technologies: want 2, got %d", techCnt)
}
// upsert again with additional tech → should merge (append)
id2, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{
URL: "https://www.httptest.example.com/",
Technologies: []string{"react", "webpack"},
TaskID: 2,
})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("http service dedup failed: %d vs %d", id2, id1)
}
d.QueryRow(`SELECT array_length(technologies, 1) FROM assets WHERE id = $1`, id1).Scan(&techCnt)
if techCnt != 4 {
t.Errorf("technologies merge: want 4, got %d", techCnt)
}
}
func TestUpsertHTTPServiceAuthAppend(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
auth1 := []map[string]any{{"type": "basic", "username": "admin", "password": "pass"}}
id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{
URL: "http://authtest.example.org/",
Auth: auth1,
TaskID: 1,
})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE url = 'http://authtest.example.org' OR domain IN ('authtest.example.org', 'example.org')`)
auth2 := []map[string]any{{"type": "bearer", "token": "tok123"}}
id2, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{
URL: "http://authtest.example.org/",
Auth: auth2,
TaskID: 2,
})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("dedup failed: %d vs %d", id2, id1)
}
// auth should have 2 items now
var authCnt int
d.QueryRow(`SELECT cardinality(auth) FROM assets WHERE id = $1`, id1).Scan(&authCnt)
if authCnt != 2 {
t.Errorf("auth append: want 2, got %d", authCnt)
}
}
// =====================================================================
// OtherService
// =====================================================================
func TestUpsertOtherService(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id1, err := av2.UpsertOtherService(UpsertOtherServiceReq{
IP: "172.16.0.1",
Port: 22,
ServiceName: "ssh",
TaskID: 1,
})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE (type = 'service' AND service_name = 'ssh' AND ip = '172.16.0.1') OR (type = 'ip' AND ip = '172.16.0.1')`)
if id1 == 0 {
t.Fatal("expected non-zero id")
}
// dedup
id2, err := av2.UpsertOtherService(UpsertOtherServiceReq{
IP: "172.16.0.1",
Port: 22,
ServiceName: "ssh",
})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("other service dedup failed: %d vs %d", id2, id1)
}
// side-effect: IP asset should have port 22 in open_ports
var cnt int
d.QueryRow(`SELECT cardinality(open_ports) FROM assets WHERE type='ip' AND ip='172.16.0.1'`).Scan(&cnt)
if cnt == 0 {
t.Error("side-effect: IP open_ports not populated")
}
}
func TestUpsertOtherServiceAuthAppend(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
auth1 := []map[string]any{{"username": "admin", "password": "secret"}}
id1, err := av2.UpsertOtherService(UpsertOtherServiceReq{
IP: "10.5.5.5",
Port: 3306,
ServiceName: "mysql",
Auth: auth1,
})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE (type = 'service' AND ip = '10.5.5.5') OR (type = 'ip' AND ip = '10.5.5.5')`)
auth2 := []map[string]any{{"username": "root", "password": "root"}}
id2, err := av2.UpsertOtherService(UpsertOtherServiceReq{
IP: "10.5.5.5",
Port: 3306,
ServiceName: "mysql",
Auth: auth2,
})
if err != nil || id2 != id1 {
t.Fatalf("dedup or error: %v, ids %d vs %d", err, id2, id1)
}
var authCnt int
d.QueryRow(`SELECT cardinality(auth) FROM assets WHERE id = $1`, id1).Scan(&authCnt)
if authCnt != 2 {
t.Errorf("auth append: want 2, got %d", authCnt)
}
}
// =====================================================================
// Endpoint
// =====================================================================
func TestUpsertEndpoint(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
params1 := []map[string]any{{"location": "query", "name": "id", "value": "1"}}
id1, err := av2.UpsertEndpoint(UpsertEndpointReq{
URL: "https://api.eptest.com/users?id=1",
Method: "GET",
Params: params1,
TaskID: 1,
})
if err != nil {
t.Fatal(err)
}
defer d.Exec(`DELETE FROM assets WHERE url LIKE '%eptest.com%' OR domain IN ('api.eptest.com', 'eptest.com')`)
if id1 == 0 {
t.Fatal("expected non-zero id")
}
// dedup (same URL + method = same endpoint)
id2, err := av2.UpsertEndpoint(UpsertEndpointReq{
URL: "https://api.eptest.com/users?id=1",
Method: "GET",
Params: []map[string]any{{"location": "query", "name": "filter", "value": "active"}},
TaskID: 2,
})
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Errorf("endpoint dedup failed: %d vs %d", id2, id1)
}
// params should be merged (2 distinct params)
var paramCnt int
d.QueryRow(`SELECT cardinality(params) FROM assets WHERE id = $1`, id1).Scan(&paramCnt)
if paramCnt != 2 {
t.Errorf("params merge: want 2, got %d", paramCnt)
}
}
func TestUpsertEndpointRequiredFields(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
_, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "", Method: "GET"})
if err == nil {
t.Error("expected error for empty URL")
}
_, err = av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://example.com/", Method: ""})
if err == nil {
t.Error("expected error for empty method")
}
}
// =====================================================================
// Query helpers
// =====================================================================
func TestQueryByType(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "querytest.example.net"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
assets, err := av2.QueryByType("root_domain", 10, 0)
if err != nil {
t.Fatal(err)
}
found := false
for _, a := range assets {
if a.ID == id {
found = true
}
}
if !found {
t.Error("QueryByType: inserted asset not found")
}
}
// TestDeleteByTaskID: 独有资产被删,与其他任务共享的资产仅解除关联(保留),host 反查正确。
func TestDeleteByTaskID(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
const taskA = int64(90001)
const taskB = int64(90002)
// solo:仅属 taskA
solo, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "solo-del.test", TaskID: taskA})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, solo)
// shared:先 taskA 再 taskB → task_ids={A,B}
shared, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "shared-del.test", TaskID: taskA})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, shared)
if _, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "shared-del.test", TaskID: taskB}); err != nil {
t.Fatal(err)
}
// host 反查(删资产前):应含两个域名
hosts, err := av2.HostsByTask(taskA)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(hosts, "solo-del.test") || !slices.Contains(hosts, "shared-del.test") {
t.Fatalf("HostsByTask 缺 host: %v", hosts)
}
n, err := av2.DeleteByTaskID(taskA)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("DeleteByTaskID: 应删 1 个独有资产,实删 %d", n)
}
// solo 已删
if a, _ := av2.GetByIDs([]int64{solo}); len(a) != 0 {
t.Fatalf("solo 资产应被删除")
}
// shared 保留,且 task_ids 只剩 taskB
sa, _ := av2.GetByIDs([]int64{shared})
if len(sa) != 1 {
t.Fatalf("shared 资产应保留")
}
if slices.Contains(sa[0].TaskIDs, taskA) || !slices.Contains(sa[0].TaskIDs, taskB) {
t.Fatalf("shared task_ids 应解除 A 保留 B,得 %v", sa[0].TaskIDs)
}
}
func TestQueryByTask(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
const taskID = int64(99999)
id, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "taskquery.net", TaskID: taskID})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
assets, err := av2.QueryByTask(taskID, "", 10, 0)
if err != nil {
t.Fatal(err)
}
found := false
for _, a := range assets {
if a.ID == id {
found = true
}
}
if !found {
t.Error("QueryByTask: inserted asset not found")
}
n, err := av2.CountByTask(taskID, "")
if err != nil {
t.Fatal(err)
}
if n < 1 {
t.Errorf("CountByTask: got %d, want >= 1", n)
}
if counts, err := av2.CountsByTypeForTask(taskID); err != nil {
t.Fatal(err)
} else if counts["root_domain"] < 1 {
t.Errorf("CountsByTypeForTask: root_domain = %d, want >= 1", counts["root_domain"])
}
// offset past the end must return nothing, not fall back to page 1
rest, err := av2.QueryByTask(taskID, "", 10, n)
if err != nil {
t.Fatal(err)
}
if len(rest) != 0 {
t.Errorf("QueryByTask offset=%d: got %d rows, want 0", n, len(rest))
}
}
// 任务资产列表按页取,不再被固定条数截断:60 条资产用 25/页要能完整翻出来。
func TestQueryByTaskPaging(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
const taskID = int64(99998)
const n = 60
for i := 0; i < n; i++ {
id, err := av2.UpsertRootDomain(UpsertRootDomainReq{
Domain: fmt.Sprintf("paging-%02d.querybytask.test", i),
TaskID: taskID,
})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
}
total, err := av2.CountByTask(taskID, "root_domain")
if err != nil {
t.Fatal(err)
}
if total != n {
t.Fatalf("CountByTask: got %d, want %d", total, n)
}
const size = 25
seen := map[int64]bool{}
for offset := 0; offset < total; offset += size {
page, err := av2.QueryByTask(taskID, "root_domain", size, offset)
if err != nil {
t.Fatal(err)
}
for _, a := range page {
if seen[a.ID] {
t.Errorf("offset=%d: asset %d returned twice", offset, a.ID)
}
seen[a.ID] = true
}
}
if len(seen) != n {
t.Errorf("paged through %d assets, want %d", len(seen), n)
}
}
func TestQueryByCompany(t *testing.T) {
d, av2, cs := testSetup(t)
defer d.Close()
companyID, _, err := cs.UpsertCompany("QueryByCompanyCorp", "")
if err != nil {
t.Fatal(err)
}
defer cleanupCompany(d, companyID)
cs.AddScope(companyID, []string{"qbc-test.io"}, "test")
id, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "qbc-test.io"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
if err := cs.RecomputeAttribution(); err != nil {
t.Fatal(err)
}
assets, err := av2.QueryByCompany(companyID, "root_domain", 10, 0)
if err != nil {
t.Fatal(err)
}
found := false
for _, a := range assets {
if a.ID == id {
found = true
}
}
if !found {
t.Error("QueryByCompany: attributed asset not found")
}
n, err := av2.CountByCompany(companyID, "root_domain")
if err != nil {
t.Fatal(err)
}
if n < 1 {
t.Errorf("CountByCompany: got %d, want >= 1", n)
}
// offset past the end must return nothing, not fall back to page 1
rest, err := av2.QueryByCompany(companyID, "root_domain", 10, n)
if err != nil {
t.Fatal(err)
}
if len(rest) != 0 {
t.Errorf("QueryByCompany offset=%d: got %d rows, want 0", n, len(rest))
}
}
// 企业资产列表同样按页取,不被固定条数截断。
func TestQueryByCompanyPaging(t *testing.T) {
d, av2, cs := testSetup(t)
defer d.Close()
companyID, _, err := cs.UpsertCompany("QueryByCompanyPagingCorp", "")
if err != nil {
t.Fatal(err)
}
defer cleanupCompany(d, companyID)
if added, _, _, errs := cs.AddScope(companyID, []string{"qbc-paging.io"}, "test"); added != 1 {
t.Fatalf("AddScope: added=%d, errors=%v", added, errs)
}
// UpsertSubdomain 会顺带建根域名资产,一并清掉
defer d.Exec(`DELETE FROM assets WHERE root_domain = 'qbc-paging.io'`)
const n = 60
for i := 0; i < n; i++ {
if _, err := av2.UpsertSubdomain(UpsertSubdomainReq{
Domain: fmt.Sprintf("paging-%02d.qbc-paging.io", i),
}); err != nil {
t.Fatal(err)
}
}
total, err := av2.CountByCompany(companyID, "subdomain")
if err != nil {
t.Fatal(err)
}
if total != n {
t.Fatalf("CountByCompany: got %d, want %d", total, n)
}
const size = 25
seen := map[int64]bool{}
for offset := 0; offset < total; offset += size {
page, err := av2.QueryByCompany(companyID, "subdomain", size, offset)
if err != nil {
t.Fatal(err)
}
for _, a := range page {
if seen[a.ID] {
t.Errorf("offset=%d: asset %d returned twice", offset, a.ID)
}
seen[a.ID] = true
}
}
if len(seen) != n {
t.Errorf("paged through %d assets, want %d", len(seen), n)
}
}
func TestCountsByType(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
id, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "counttest.example.biz"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, id)
counts, err := av2.CountsByType()
if err != nil {
t.Fatal(err)
}
if counts["root_domain"] == 0 {
t.Error("CountsByType: root_domain count should be > 0")
}
}
// =====================================================================
// Company attribution at insert time
// =====================================================================
func TestCompanyAttributionAtInsertTime(t *testing.T) {
d, av2, cs := testSetup(t)
defer d.Close()
companyID, _, err := cs.UpsertCompany("InsertAttrCorp", "")
if err != nil {
t.Fatal(err)
}
defer cleanupCompany(d, companyID)
cs.AddScope(companyID, []string{"insertattr.com"}, "test")
// Now insert a root domain — should be auto-attributed
assetID, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: "insertattr.com"})
if err != nil {
t.Fatal(err)
}
defer deleteAsset(d, assetID)
var cid *int64
d.QueryRow(`SELECT company_id FROM assets WHERE id = $1`, assetID).Scan(&cid)
if cid == nil || *cid != companyID {
t.Errorf("attribution at insert time: want company %d, got %v", companyID, cid)
}
}
// =====================================================================
// calcCSegment
// =====================================================================
func TestCalcCSegment(t *testing.T) {
tests := []struct {
ip string
want string
}{
{"192.168.1.5", "192.168.1.0/24"},
{"10.0.0.255", "10.0.0.0/24"},
{"", ""},
{"notanip", ""},
}
for _, tc := range tests {
got := calcCSegment(tc.ip)
if got != tc.want {
t.Errorf("calcCSegment(%q): want %q, got %q", tc.ip, tc.want, got)
}
}
}
// =====================================================================
// marshalStringArray
// =====================================================================
func TestMarshalStringArray(t *testing.T) {
got := marshalStringArray([]string{"a", "b", "c"})
if got != `{"a","b","c"}` {
t.Errorf("unexpected: %q", got)
}
got2 := marshalStringArray(nil)
if got2 != "{}" {
t.Errorf("empty: %q", got2)
}
}
// =====================================================================
// normalizeURL
// =====================================================================
func TestNormalizeURL(t *testing.T) {
tests := []struct {
raw string
want string
}{
{"HTTPS://Example.COM/path", "https://example.com/path"},
{"http://example.com/", "http://example.com"},
{"http://example.com/page", "http://example.com/page"},
}
for _, tc := range tests {
got := normalizeURL(tc.raw)
if got != tc.want {
t.Errorf("normalizeURL(%q): want %q, got %q", tc.raw, tc.want, got)
}
}
}
func TestAssetPaginationUsesStableIDTieBreaker(t *testing.T) {
d, assets, _ := testSetup(t)
defer d.Close()
stamp := time.Now().UnixNano()
taskID := stamp
marker := fmt.Sprintf("stable-page-%d", stamp)
sharedSeen := time.Date(2026, time.August, 21, 9, 0, 0, 0, time.UTC)
ids := make([]int64, 0, 5)
for i := 0; i < 5; i++ {
var id int64
domain := fmt.Sprintf("%s-%d.invalid", marker, i)
if err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,task_ids,last_seen)
VALUES ('root_domain',$1,$1,ARRAY[$2]::bigint[],$3) RETURNING id`,
domain, taskID, sharedSeen).Scan(&id); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
defer func() { _, _ = d.Exec(`DELETE FROM assets WHERE id=ANY($1::bigint[])`, ids) }()
want := slices.Clone(ids)
slices.Reverse(want)
collect := func(query func(limit, offset int) ([]*Asset, error)) []int64 {
t.Helper()
var got []int64
for offset := 0; offset < len(ids); offset += 2 {
page, err := query(2, offset)
if err != nil {
t.Fatal(err)
}
for _, asset := range page {
got = append(got, asset.ID)
}
}
return got
}
if got := collect(func(limit, offset int) ([]*Asset, error) {
return assets.QueryByTask(taskID, "root_domain", limit, offset)
}); !slices.Equal(got, want) {
t.Fatalf("task pagination order=%v want=%v", got, want)
}
if got := collect(func(limit, offset int) ([]*Asset, error) {
return assets.QueryDSL(marker, "root_domain", 0, limit, offset)
}); !slices.Equal(got, want) {
t.Fatalf("DSL pagination order=%v want=%v", got, want)
}
}
// TestAssetIPRejectsHostname pins the write-side guard added after a hostname in
// assets.ip broke company attribution: every asset type that persists an ip must
// refuse a non-address, and the message must tell the caller how to fix it.
func TestAssetIPRejectsHostname(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
hostname := "d63cd476.cdn.ucloud.com.cn"
cases := []struct {
name string
call func() (int64, error)
}{
{"ip", func() (int64, error) {
return av2.UpsertIP(UpsertIPReq{IP: hostname})
}},
{"http service", func() (int64, error) {
return av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://example.com/", IP: hostname})
}},
{"other service", func() (int64, error) {
return av2.UpsertOtherService(UpsertOtherServiceReq{IP: hostname, Port: 22, ServiceName: "ssh"})
}},
{"endpoint", func() (int64, error) {
return av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://example.com/a", Method: "GET", IP: hostname})
}},
}
for _, tc := range cases {
id, err := tc.call()
if !errors.Is(err, ErrAssetIPInvalid) {
deleteAsset(d, id)
t.Fatalf("%s: err=%v, want %v", tc.name, err, ErrAssetIPInvalid)
}
// The caller has to learn the remedy, not just that something was wrong.
if !strings.Contains(err.Error(), hostname) || !strings.Contains(err.Error(), "type=subdomain") {
t.Fatalf("%s: message is not actionable: %v", tc.name, err)
}
}
// An empty ip stays legal for the types where it is optional, and a real
// address is still accepted.
id, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://ip-guard.example.com/"})
if err != nil {
t.Fatalf("empty ip rejected: %v", err)
}
deleteAsset(d, id)
id, err = av2.UpsertIP(UpsertIPReq{IP: "203.0.113.7"})
if err != nil {
t.Fatalf("valid ip rejected: %v", err)
}
deleteAsset(d, id)
}
// TestQueryDSLInScopeMembership pins the agent-facing list_assets behavior: it
// returns assets that BELONG to the task's declared scope (membership, not literal
// value) — a root_domain scope surfaces its subdomains/services/endpoints even when
// those rows were produced by a different task — and honors ip/cidr scope for
// IP-literal hosts (the try_inet(domain) arm). Out-of-scope assets, including by id,
// are excluded. Direct source tasks' scope is included.
func TestQueryDSLInScopeMembership(t *testing.T) {
d, assets, _ := testSetup(t)
defer d.Close()
task, err := d.CreateTask("scope membership", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
src, err := d.CreateTask("scope source", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID); _ = d.DeleteTask(src.ID) })
// task ← src is a direct source relation.
if _, err := d.Exec(`INSERT INTO task_relations(task_id, source_task_id) VALUES ($1,$2)`, task.ID, src.ID); err != nil {
t.Fatal(err)
}
stamp := time.Now().UnixNano()
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task)
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows
foreignTask := stamp + 777 // asset produced by an unrelated task
// Scope: task owns root; source task owns srcRoot; task owns an IP /24.
for _, sc := range []struct {
tid int64
kind string
dom string
net string
}{
{task.ID, "root_domain", root, ""},
{src.ID, "root_domain", srcRoot, ""},
{task.ID, "ip", "", "198.51.100.0/24"},
} {
if err := assets.upsertTaskScope(TaskScope{TaskID: sc.tid, Kind: sc.kind, Domain: sc.dom, Net: sc.net}); err != nil {
t.Fatalf("seed scope: %v", err)
}
}
// Insert rows directly so root_domain/task_ids are controlled exactly. Every
// in-scope row carries foreignTask in task_ids (never the current task) to prove
// membership is by scope, not by producer.
type row struct {
typ, domain, rootDom, url, method, ip, title string
}
rows := []row{
{"subdomain", "api." + root, root, "", "", "", marker}, // under task root
{"service", "www." + root, root, "https://www." + root + "/", "", "", marker}, // service under task root
{"endpoint", "www." + root, root, "https://www." + root + "/a?" + marker + "=1", "GET", "", ""},
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
{"endpoint", "198.51.100.9", "198.51.100.9", "http://198.51.100.9:8080/" + marker, "GET", "", ""}, // IP-literal host, ip col empty
{"subdomain", "x." + out, out, "", "", "", marker}, // out of scope
}
var inScopeIDs, outIDs []int64
for _, r := range rows {
var id int64
if err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,method,ip,page_title,task_ids,last_seen)
VALUES ($1,NULLIF($2,''),NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),NULLIF($7,''),ARRAY[$8]::bigint[],now())
RETURNING id`,
r.typ, r.domain, r.rootDom, r.url, r.method, r.ip, r.title, foreignTask).Scan(&id); err != nil {
t.Fatalf("insert %s: %v", r.domain, err)
}
if r.rootDom == out {
outIDs = append(outIDs, id)
} else {
inScopeIDs = append(inScopeIDs, id)
}
}
t.Cleanup(func() {
_, _ = d.Exec(`DELETE FROM assets WHERE id=ANY($1::bigint[]) OR id=ANY($2::bigint[])`, inScopeIDs, outIDs)
})
got, err := assets.QueryDSLInScope(marker, "", task.ID, 50, 0)
if err != nil {
t.Fatal(err)
}
gotIDs := map[int64]bool{}
for _, a := range got {
gotIDs[a.ID] = true
}
for _, id := range inScopeIDs {
if !gotIDs[id] {
t.Fatalf("in-scope asset %d missing from QueryDSLInScope result %v", id, gotIDs)
}
}
for _, id := range outIDs {
if gotIDs[id] {
t.Fatalf("out-of-scope asset %d leaked into scoped query", id)
}
}
// GetByIDsInScope: in-scope id returns, out-of-scope id is dropped.
mixed := append(append([]int64{}, inScopeIDs[0]), outIDs[0])
byID, err := assets.GetByIDsInScope(task.ID, mixed)
if err != nil {
t.Fatal(err)
}
if len(byID) != 1 || byID[0].ID != inScopeIDs[0] {
t.Fatalf("GetByIDsInScope mixed ids → %+v, want only %d", byID, inScopeIDs[0])
}
// taskID<=0 (non-task context) falls back to global: the out-of-scope row is reachable.
global, err := assets.QueryDSLInScope(marker, "", 0, 50, 0)
if err != nil {
t.Fatal(err)
}
var sawOut bool
for _, a := range global {
if a.ID == outIDs[0] {
sawOut = true
}
}
if !sawOut {
t.Fatalf("taskID<=0 should fall back to global and include out-of-scope asset %d", outIDs[0])
}
}