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(¶mCnt) 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]) } }