mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 20:20:54 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e43b423ee | ||
|
|
8bb4ca0745 | ||
|
|
939f8d4976 | ||
|
|
b5c0bb9f33 | ||
|
|
9d75df335f | ||
|
|
fe9814c5f6 | ||
|
|
53fc8478da | ||
|
|
edc2f4da6d | ||
|
|
01a9dae49e | ||
|
|
4e126a8524 |
@@ -114,6 +114,7 @@ func bbotTyped(raw bbotRawEvent) bbotEvent {
|
||||
}
|
||||
if u, err := url.Parse(strings.TrimSpace(raw.URL)); err == nil && ev.Host != "" &&
|
||||
(u.Scheme == "http" || u.Scheme == "https") && strings.EqualFold(u.Hostname(), ev.Host) {
|
||||
u.User = nil // credentials never reach an asset
|
||||
ev.URL = u.String()
|
||||
}
|
||||
return ev
|
||||
|
||||
@@ -71,3 +71,15 @@ func TestParseBbotJSONLTypedOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The typed url of an in-scope URL event loses its userinfo, keeps its query.
|
||||
func TestParseBbotURLUserinfo(t *testing.T) {
|
||||
res, err := parseBbotJSONL(strings.NewReader(`{"type":"URL","host":"www.example.com","url":"https://scanuser:s3cr3t@www.example.com/app?id=7","scope_distance":0}` + "\n"))
|
||||
if err != nil || len(res.InScope) != 1 {
|
||||
t.Fatalf("parse: %v, %+v", err, res)
|
||||
}
|
||||
want := []assetInputItem{{Type: "service", URL: "https://www.example.com/app?id=7"}}
|
||||
if got := res.assetItems(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("assetItems = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-5
@@ -83,8 +83,8 @@ func scanHost(h string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// scanURL returns raw re-serialized and its host when it is an http(s) URL
|
||||
// with a valid host, "" otherwise.
|
||||
// scanURL returns raw re-serialized without its userinfo, and its host, when
|
||||
// it is an http(s) URL with a valid host, "" otherwise.
|
||||
func scanURL(raw string) (string, string) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
@@ -94,9 +94,31 @@ func scanURL(raw string) (string, string) {
|
||||
if h == "" {
|
||||
return "", ""
|
||||
}
|
||||
u.User = nil // credentials never reach an asset, a note or a fact
|
||||
return u.String(), h
|
||||
}
|
||||
|
||||
// stripUserinfo removes the userinfo (user:pass@) from raw, a URL or a bare
|
||||
// host[:port] as a scanner prints it, and keeps every other byte. It is for
|
||||
// the raw values kept in a fact detail: unlike scanURL it never rejects, and a
|
||||
// value with no userinfo, or a path with no authority, is returned unchanged.
|
||||
func stripUserinfo(raw string) string {
|
||||
start := 0 // the authority follows "scheme://" or "//"; a bare value is all authority
|
||||
if i := strings.Index(raw, "//"); i == 0 || i > 0 && raw[i-1] == ':' && !strings.ContainsAny(raw[:i-1], "/?#@") {
|
||||
start = i + 2
|
||||
} else if strings.ContainsAny(raw, "/?#") {
|
||||
return raw
|
||||
}
|
||||
auth := raw[start:]
|
||||
if j := strings.IndexAny(auth, "/?#"); j >= 0 {
|
||||
auth = auth[:j]
|
||||
}
|
||||
if at := strings.LastIndex(auth, "@"); at >= 0 {
|
||||
return raw[:start] + raw[start+at+1:]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// scanHostOf reads a host from a URL, a host:port or a bare host.
|
||||
func scanHostOf(s string) string {
|
||||
if _, h := scanURL(s); h != "" {
|
||||
@@ -342,7 +364,7 @@ func (s *scanIngest) addNuclei(line []byte) {
|
||||
}
|
||||
s.note("results", tid+" ("+sev+") on "+host)
|
||||
s.fact(fmt.Sprintf("nuclei: template %s (severity %s) matched on %s; unverified scanner result", tid, sev, host),
|
||||
map[string]any{"name": r.Info.Name, "host": r.Host, "matched_at": r.MatchedAt,
|
||||
map[string]any{"name": r.Info.Name, "host": stripUserinfo(r.Host), "matched_at": stripUserinfo(r.MatchedAt),
|
||||
"matcher_name": r.MatcherName, "extracted_results": r.Extracted})
|
||||
}
|
||||
|
||||
@@ -394,7 +416,7 @@ func (s *scanIngest) addHttpx(line []byte) {
|
||||
s.note("urls", fmt.Sprintf("%s %d", u, r.StatusCode))
|
||||
s.fact(fmt.Sprintf("httpx: %s responded with status %d", u, r.StatusCode),
|
||||
map[string]any{"title": r.Title, "tech": r.Tech, "webserver": r.Webserver, "content_length": r.ContentLength,
|
||||
"host": r.Host, "ip": r.IP, "tls": r.TLS})
|
||||
"host": stripUserinfo(r.Host), "ip": r.IP, "tls": r.TLS})
|
||||
}
|
||||
|
||||
// ---- ffuf -of json ----
|
||||
@@ -480,5 +502,5 @@ func (s *scanIngest) addFfuf(raw json.RawMessage) {
|
||||
s.Assets = append(s.Assets, item)
|
||||
s.note("urls", fmt.Sprintf("%s %d", u, r.Status))
|
||||
s.fact(fmt.Sprintf("ffuf: %s returned status %d, %d bytes, %d words, %d lines", u, r.Status, r.Length, r.Words, r.Lines),
|
||||
map[string]any{"input": r.Input, "redirectlocation": r.Redirect})
|
||||
map[string]any{"input": r.Input, "redirectlocation": stripUserinfo(r.Redirect)})
|
||||
}
|
||||
|
||||
@@ -248,6 +248,137 @@ func TestScanIngestJSONLLineLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// scanURL drops the userinfo and keeps scheme, host, port, path and query; an
|
||||
// @ in the path is not userinfo; a URL it refused before is still refused.
|
||||
func TestScanURLUserinfo(t *testing.T) {
|
||||
cases := []struct{ raw, url, host string }{
|
||||
{"http://scanuser:s3cr3t@scan-ingest.example.com:8080/app?id=7&x=y", "http://scan-ingest.example.com:8080/app?id=7&x=y", "scan-ingest.example.com"},
|
||||
{"https://scanuser@scan-ingest.example.com/app", "https://scan-ingest.example.com/app", "scan-ingest.example.com"},
|
||||
{"https://scan-ingest.example.com/a@b/c?x=@y", "https://scan-ingest.example.com/a@b/c?x=@y", "scan-ingest.example.com"},
|
||||
{"http://scanuser:s3cr3t@scan-ingest.example.com/%zz", "", ""},
|
||||
{"javascript:alert(1)", "", ""},
|
||||
{"http://scanuser:s3cr3t@bad host/", "", ""},
|
||||
{"not a url", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if u, h := scanURL(c.raw); u != c.url || h != c.host {
|
||||
t.Errorf("scanURL(%q) = %q, %q; want %q, %q", c.raw, u, h, c.url, c.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUserinfo(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://scanuser:s3cr3t@scan-ingest.example.com:443/login?next=1": "https://scan-ingest.example.com:443/login?next=1",
|
||||
"http://scanuser:s3cr3t@scan-ingest.example.com/%zz": "http://scan-ingest.example.com/%zz",
|
||||
"//scanuser:s3cr3t@scan-ingest.example.com/": "//scan-ingest.example.com/",
|
||||
"scanuser:s3cr3t@scan-ingest.example.com:443": "scan-ingest.example.com:443",
|
||||
"https://scan-ingest.example.com/a@b?x=@y": "https://scan-ingest.example.com/a@b?x=@y",
|
||||
"https://scan-ingest.example.com/?r=http://scanuser:s3cr3t@x/": "https://scan-ingest.example.com/?r=http://scanuser:s3cr3t@x/",
|
||||
"/admin@x/": "/admin@x/",
|
||||
"scan-ingest.example.com:443": "scan-ingest.example.com:443",
|
||||
"198.51.100.77": "198.51.100.77",
|
||||
"": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := stripUserinfo(raw); got != want {
|
||||
t.Errorf("stripUserinfo(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner lines carrying user:pass: no asset, url, notable item or fact keeps
|
||||
// the credentials, and the query survives.
|
||||
const (
|
||||
nucleiUserinfoFixture = `{"template-id":"tech-detect","info":{"severity":"info"},"host":"https://scanuser:s3cr3t@scan-ingest.example.com","matched-at":"https://scanuser:s3cr3t@scan-ingest.example.com/login?next=1"}
|
||||
{"template-id":"tech-detect","info":{"severity":"info"},"host":"scanuser:s3cr3t@scan-ingest.example.com:443","matched-at":"https://scanuser:s3cr3t@scan-ingest.example.com:443/x"}
|
||||
`
|
||||
httpxUserinfoFixture = `{"url":"https://scanuser:s3cr3t@scan-ingest.example.com/app?id=7","status_code":200,"host":"198.51.100.77"}
|
||||
`
|
||||
ffufUserinfoFixture = `{"results":[{"input":{"FUZZ":"app"},"status":301,"url":"https://scanuser:s3cr3t@scan-ingest.example.com/app?id=7","redirectlocation":"https://scanuser:s3cr3t@scan-ingest.example.com/app/"}]}
|
||||
`
|
||||
)
|
||||
|
||||
// hasScanCreds reports whether v holds a user or password of the fixtures.
|
||||
func hasScanCreds(v string) bool {
|
||||
for _, c := range []string{"s3cr3t", "scanuser", "alice", "bob"} {
|
||||
if strings.Contains(v, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkNoUserinfo(t *testing.T, format string, s *scanIngest) {
|
||||
t.Helper()
|
||||
var all []string
|
||||
for _, a := range s.Assets {
|
||||
all = append(all, a.URL)
|
||||
}
|
||||
for u := range s.urls {
|
||||
all = append(all, u)
|
||||
}
|
||||
for _, items := range s.Notable {
|
||||
all = append(all, items...)
|
||||
}
|
||||
for _, f := range s.Facts {
|
||||
d, _ := strconv.Unquote(f.Detail)
|
||||
all = append(all, f.Summary, d)
|
||||
}
|
||||
for _, v := range all {
|
||||
if hasScanCreds(v) {
|
||||
t.Fatalf("%s: credentials kept in %q", format, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanIngestStripsUserinfo(t *testing.T) {
|
||||
cases := []struct {
|
||||
format, content string
|
||||
want string // events urls facts assets
|
||||
url string // first asset URL
|
||||
}{
|
||||
{"nuclei-jsonl", nucleiUserinfoFixture, "[2 2 2 2]", "https://scan-ingest.example.com"},
|
||||
{"httpx-jsonl", httpxUserinfoFixture, "[1 1 1 1]", "https://scan-ingest.example.com/app?id=7"},
|
||||
{"ffuf-json", ffufUserinfoFixture, "[1 1 1 1]", "https://scan-ingest.example.com/app?id=7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := parseScan(t, c.format, c.content)
|
||||
if got := fmt.Sprint([]int{s.Events, len(s.urls), len(s.Facts), len(s.Assets)}); got != c.want || s.Skipped != 0 {
|
||||
t.Fatalf("%s: events,urls,facts,assets = %s, want %s (skipped %d)", c.format, got, c.want, s.Skipped)
|
||||
}
|
||||
if s.Assets[0].URL != c.url {
|
||||
t.Fatalf("%s: asset URL %q, want %q", c.format, s.Assets[0].URL, c.url)
|
||||
}
|
||||
checkNoUserinfo(t, c.format, s)
|
||||
}
|
||||
// The raw values kept in the fact detail lose only their userinfo.
|
||||
s := parseScan(t, "nuclei-jsonl", nucleiUserinfoFixture)
|
||||
if d, _ := strconv.Unquote(s.Facts[1].Detail); !strings.Contains(d, `"host":"scan-ingest.example.com:443"`) ||
|
||||
!strings.Contains(d, `"matched_at":"https://scan-ingest.example.com:443/x"`) {
|
||||
t.Fatalf("nuclei detail %s", d)
|
||||
}
|
||||
s = parseScan(t, "ffuf-json", ffufUserinfoFixture)
|
||||
if d, _ := strconv.Unquote(s.Facts[0].Detail); !strings.Contains(d, `"redirectlocation":"https://scan-ingest.example.com/app/"`) {
|
||||
t.Fatalf("ffuf detail %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The same URL with different credentials is one URL after normalisation:
|
||||
// both events are counted, both assets carry the same URL.
|
||||
func TestScanIngestUserinfoDedup(t *testing.T) {
|
||||
s := parseScan(t, "httpx-jsonl", httpxUserinfoDedupFixture)
|
||||
if s.Events != 2 || len(s.urls) != 1 || len(s.Assets) != 2 || s.Assets[0].URL != s.Assets[1].URL ||
|
||||
s.Assets[0].URL != "https://scan-ingest.example.com/app?id=7" {
|
||||
t.Fatalf("events=%d urls=%d assets=%+v", s.Events, len(s.urls), s.Assets)
|
||||
}
|
||||
checkNoUserinfo(t, "httpx-jsonl", s)
|
||||
}
|
||||
|
||||
const httpxUserinfoDedupFixture = `{"url":"https://alice:s3cr3t-one@scan-ingest.example.com/app?id=7","status_code":200}
|
||||
{"url":"https://bob:s3cr3t-two@scan-ingest.example.com/app?id=7","status_code":200}
|
||||
`
|
||||
|
||||
// Past scanMaxItems assets and facts, records are counted, not ingested.
|
||||
func TestScanIngestItemCap(t *testing.T) {
|
||||
var b strings.Builder
|
||||
|
||||
@@ -199,3 +199,78 @@ func TestIngestScanOutputJSONPG(t *testing.T) {
|
||||
t.Fatalf("re-ingestion: %d assets and %d facts, first run %d and %d", a, f, assets, facts)
|
||||
}
|
||||
}
|
||||
|
||||
// ingest_scan_output (PG), credentials in URLs: no asset row and no fact keeps
|
||||
// them, and the same URL with different credentials is one asset row and one
|
||||
// fact.
|
||||
func TestIngestScanOutputUserinfoPG(t *testing.T) {
|
||||
d := testDB(t)
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
task, err := d.CreateTaskWithOptions("ingest_scan_output_userinfo", "scan-ingest.example.com", db.TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const assetsWhere = `domain LIKE '%scan-ingest.example.com' OR url LIKE '%scan-ingest.example.com%'`
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE ` + assetsWhere)
|
||||
})
|
||||
store := d.Exploration(task.ExplorationID)
|
||||
tsx := NewToolSet(store, "worker-1")
|
||||
tsx.SetTaskID(task.ID)
|
||||
tsx.SetAssetStore(d.Assets(), d.Companies())
|
||||
dir := t.TempDir()
|
||||
tool := tsx.ingestScanOutput()
|
||||
ingest := func(format, content string) map[string]any {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "scan.out"), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, _ := json.Marshal(map[string]string{"format": format, "path": "scan.out"})
|
||||
res, err := tool.Call(context.Background(), in, &actool.ToolContext{WorkingDir: dir})
|
||||
var out map[string]any
|
||||
if err != nil || res.IsError || json.Unmarshal([]byte(res.Flatten()), &out) != nil ||
|
||||
out["asset_errors"] != nil || out["fact_errors"] != nil {
|
||||
t.Fatalf("%s: %v %s", format, err, res.Flatten())
|
||||
}
|
||||
return out
|
||||
}
|
||||
count := func(q string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := d.QueryRow(q).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
out := ingest("httpx-jsonl", httpxUserinfoDedupFixture)
|
||||
if out["events"] != 2.0 || out["urls"] != 1.0 || out["facts_written"] != 1.0 || out["facts_existing"] != 1.0 {
|
||||
t.Fatalf("same URL, different credentials: %v", out)
|
||||
}
|
||||
const appURL = `SELECT count(*) FROM assets WHERE type='service' AND url='https://scan-ingest.example.com/app?id=7'`
|
||||
if n := count(appURL); n != 1 {
|
||||
t.Fatalf("%d asset rows for the normalised URL, want 1", n)
|
||||
}
|
||||
ingest("httpx-jsonl", httpxUserinfoFixture)
|
||||
ingest("ffuf-json", ffufUserinfoFixture)
|
||||
ingest("nuclei-jsonl", nucleiUserinfoFixture)
|
||||
if n := count(appURL); n != 1 {
|
||||
t.Fatalf("%d asset rows for the normalised URL after httpx and ffuf, want 1", n)
|
||||
}
|
||||
if n := count(`SELECT count(*) FROM assets a WHERE (` + assetsWhere + `) AND row_to_json(a)::text ~ 's3cr3t|scanuser|alice|bob'`); n != 0 {
|
||||
t.Fatalf("%d asset rows keep credentials", n)
|
||||
}
|
||||
facts, err := store.ListByKind(db.KindFact, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
t.Fatal("no facts written")
|
||||
}
|
||||
for _, f := range facts {
|
||||
if hasScanCreds(string(f.Payload)) {
|
||||
t.Fatalf("fact keeps credentials: %s", f.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -160,7 +160,8 @@ func calcCSegment(ipStr string) string {
|
||||
return ipnet.String()
|
||||
}
|
||||
|
||||
// normalizeURL lowercases scheme and host, strips trailing slash from bare roots.
|
||||
// normalizeURL lowercases scheme and host, drops userinfo (credentials must
|
||||
// never reach assets.url), and strips trailing slash from bare roots.
|
||||
func normalizeURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -168,6 +169,7 @@ func normalizeURL(raw string) string {
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.User = nil
|
||||
s := u.String()
|
||||
if strings.HasSuffix(s, "/") && u.Path == "/" && u.RawQuery == "" {
|
||||
s = strings.TrimSuffix(s, "/")
|
||||
|
||||
+144
-3
@@ -893,6 +893,10 @@ func TestNormalizeURL(t *testing.T) {
|
||||
{"HTTPS://Example.COM/path", "https://example.com/path"},
|
||||
{"http://example.com/", "http://example.com"},
|
||||
{"http://example.com/page", "http://example.com/page"},
|
||||
{"https://u:p@Example.com/app?x=1", "https://example.com/app?x=1"},
|
||||
{"https://user@example.com/", "https://example.com"},
|
||||
{"https://u:p@example.com:8443/users/@alice?x=1", "https://example.com:8443/users/@alice?x=1"},
|
||||
{"https://example.com/@me?r=http://a:b@x/", "https://example.com/@me?r=http://a:b@x/"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := normalizeURL(tc.raw)
|
||||
@@ -902,6 +906,139 @@ func TestNormalizeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// storedURLRow reads back the url, domain and port of one asset row.
|
||||
func storedURLRow(t *testing.T, d *DB, id int64) (string, string, int) {
|
||||
t.Helper()
|
||||
var u, domain *string
|
||||
var port *int
|
||||
if err := d.QueryRow(`SELECT url, domain, port FROM assets WHERE id = $1`, id).Scan(&u, &domain, &port); err != nil {
|
||||
t.Fatalf("read asset %d: %v", id, err)
|
||||
}
|
||||
var us, ds string
|
||||
var p int
|
||||
if u != nil {
|
||||
us = *u
|
||||
}
|
||||
if domain != nil {
|
||||
ds = *domain
|
||||
}
|
||||
if port != nil {
|
||||
p = *port
|
||||
}
|
||||
return us, ds, p
|
||||
}
|
||||
|
||||
func TestUpsertHTTPServiceStripsUserinfo(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
cleanup := func() {
|
||||
d.Exec(`DELETE FROM assets WHERE url LIKE '%storeuserinfo-svc.com%' OR domain LIKE '%storeuserinfo-svc.com' OR root_domain = 'storeuserinfo-svc.com'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://alice:s3cr3t-one@App.storeuserinfo-svc.com/app?x=1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, domain, port := storedURLRow(t, d, id1)
|
||||
if u != "https://app.storeuserinfo-svc.com/app?x=1" || domain != "app.storeuserinfo-svc.com" || port != 443 {
|
||||
t.Fatalf("stored url=%q domain=%q port=%d", u, domain, port)
|
||||
}
|
||||
|
||||
// Same URL, different credentials: deduplicated onto the same row.
|
||||
id2, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://bob:s3cr3t-two@app.storeuserinfo-svc.com/app?x=1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Errorf("dedup across credentials failed: %d vs %d", id2, id1)
|
||||
}
|
||||
var n int
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE type = 'service' AND service_type = 'http' AND url LIKE '%storeuserinfo-svc.com/app%'`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 http service row, got %d", n)
|
||||
}
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE url LIKE '%s3cr3t%' AND url LIKE '%storeuserinfo-svc.com%'`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("credentials stored in %d asset url(s)", n)
|
||||
}
|
||||
|
||||
// @ in the path is kept; only the userinfo goes.
|
||||
id3, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://u:p@app.storeuserinfo-svc.com:8443/users/@alice?x=1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, domain, port = storedURLRow(t, d, id3)
|
||||
if u != "https://app.storeuserinfo-svc.com:8443/users/@alice?x=1" || domain != "app.storeuserinfo-svc.com" || port != 8443 {
|
||||
t.Errorf("@ path: stored url=%q domain=%q port=%d", u, domain, port)
|
||||
}
|
||||
|
||||
// Regression: a URL without userinfo is stored unchanged.
|
||||
plain := "https://plain.storeuserinfo-svc.com/@me?q=1"
|
||||
id4, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: plain})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _, _ = storedURLRow(t, d, id4); u != plain {
|
||||
t.Errorf("plain url: want %q, got %q", plain, u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEndpointStripsUserinfo(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
cleanup := func() {
|
||||
d.Exec(`DELETE FROM assets WHERE url LIKE '%storeuserinfo-ep.com%' OR domain LIKE '%storeuserinfo-ep.com' OR root_domain = 'storeuserinfo-ep.com'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
id1, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://alice:s3cr3t-one@api.storeuserinfo-ep.com/users?id=1", Method: "GET"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, domain, port := storedURLRow(t, d, id1)
|
||||
if u != "https://api.storeuserinfo-ep.com/users?id=1" || domain != "api.storeuserinfo-ep.com" || port != 443 {
|
||||
t.Fatalf("stored url=%q domain=%q port=%d", u, domain, port)
|
||||
}
|
||||
|
||||
id2, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://bob:s3cr3t-two@api.storeuserinfo-ep.com/users?id=1", Method: "GET"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Errorf("endpoint dedup across credentials failed: %d vs %d", id2, id1)
|
||||
}
|
||||
var n int
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE type = 'endpoint' AND url LIKE '%storeuserinfo-ep.com/users%'`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 endpoint row, got %d", n)
|
||||
}
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE url LIKE '%s3cr3t%' AND url LIKE '%storeuserinfo-ep.com%'`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("credentials stored in %d asset url(s)", n)
|
||||
}
|
||||
|
||||
id3, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "http://u:p@api.storeuserinfo-ep.com/users/@alice?x=1", Method: "POST"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _, port = storedURLRow(t, d, id3)
|
||||
if u != "http://api.storeuserinfo-ep.com/users/@alice?x=1" || port != 80 {
|
||||
t.Errorf("@ path: stored url=%q port=%d", u, port)
|
||||
}
|
||||
|
||||
plain := "https://plain.storeuserinfo-ep.com/@me?q=1"
|
||||
id4, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: plain, Method: "GET"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _, _ = storedURLRow(t, d, id4); u != plain {
|
||||
t.Errorf("plain url: want %q, got %q", plain, u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetPaginationUsesStableIDTieBreaker(t *testing.T) {
|
||||
d, assets, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
@@ -1010,7 +1147,8 @@ func TestAssetIPRejectsHostname(t *testing.T) {
|
||||
// are excluded. Direct source tasks' scope is included.
|
||||
func TestQueryDSLInScopeMembership(t *testing.T) {
|
||||
d, assets, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
task, err := d.CreateTask("scope membership", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
@@ -1020,7 +1158,10 @@ func TestQueryDSLInScopeMembership(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID); _ = d.DeleteTask(src.ID) })
|
||||
t.Cleanup(func() {
|
||||
cleanupErr(t, "task", d.DeleteTask(task.ID))
|
||||
cleanupErr(t, "source task", 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)
|
||||
@@ -1079,7 +1220,7 @@ func TestQueryDSLInScopeMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE id=ANY($1::bigint[]) OR id=ANY($2::bigint[])`, inScopeIDs, outIDs)
|
||||
cleanupExec(t, d, `DELETE FROM assets WHERE id=ANY($1::bigint[]) OR id=ANY($2::bigint[])`, inScopeIDs, outIDs)
|
||||
})
|
||||
|
||||
got, err := assets.QueryDSLInScope(marker, "", task.ID, 50, 0)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These helpers report a failed test cleanup instead of swallowing it: a silent
|
||||
// failure leaves the test's rows behind for the next run on the same database.
|
||||
|
||||
// cleanupErr reports err from the cleanup step described by what.
|
||||
func cleanupErr(t *testing.T, what string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("cleanup %s: %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExec runs one cleanup statement. Matching no row is not an error.
|
||||
func cleanupExec(t *testing.T, d *DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
_, err := d.Exec(query, args...)
|
||||
cleanupErr(t, query, err)
|
||||
}
|
||||
|
||||
// cleanupProfile deletes a profile through DeleteProfile. A profile the test
|
||||
// already deleted is not an error.
|
||||
func cleanupProfile(t *testing.T, d *DB, id int64) {
|
||||
t.Helper()
|
||||
if err := d.DeleteProfile(id); !errors.Is(err, ErrLLMProfileNotFound) {
|
||||
cleanupErr(t, fmt.Sprintf("profile %d", id), err)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
|
||||
func TestAssetUpsertWaitsForCompanyScopeMutation(t *testing.T) {
|
||||
d, assets, companies := testSetup(t)
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
domain := fmt.Sprintf("scope-lock-%d.example", stamp)
|
||||
@@ -23,8 +24,8 @@ func TestAssetUpsertWaitsForCompanyScopeMutation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE domain = $1`, domain)
|
||||
_, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2)`, oldCompany, newCompany)
|
||||
cleanupExec(t, d, `DELETE FROM assets WHERE domain = $1`, domain)
|
||||
cleanupExec(t, d, `DELETE FROM companies WHERE id IN ($1,$2)`, oldCompany, newCompany)
|
||||
})
|
||||
if added, _, invalid, validationErrors, err := companies.AddScopeInputsChecked(oldCompany, []ScopeInput{
|
||||
{Kind: "domain", Value: domain},
|
||||
@@ -90,7 +91,8 @@ func TestAssetUpsertWaitsForCompanyScopeMutation(t *testing.T) {
|
||||
|
||||
func TestResolveAndRecomputeUseStableCompanyIDTieBreak(t *testing.T) {
|
||||
d, assets, companies := testSetup(t)
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
first, _, err := companies.UpsertCompany(fmt.Sprintf("Tie Break First %d", stamp), "")
|
||||
@@ -109,8 +111,8 @@ func TestResolveAndRecomputeUseStableCompanyIDTieBreak(t *testing.T) {
|
||||
icp := fmt.Sprintf("ICP-TIE-%d", stamp)
|
||||
appName := fmt.Sprintf("Tie Break App %d", stamp)
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE domain=$1 OR ip=$2 OR app_name=$3`, domain, ip, appName)
|
||||
_, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2)`, first, second)
|
||||
cleanupExec(t, d, `DELETE FROM assets WHERE domain=$1 OR ip=$2 OR app_name=$3`, domain, ip, appName)
|
||||
cleanupExec(t, d, `DELETE FROM companies WHERE id IN ($1,$2)`, first, second)
|
||||
})
|
||||
|
||||
rules := []ScopeInput{
|
||||
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -331,19 +332,36 @@ func TestCompanyScopeAttribution(t *testing.T) {
|
||||
d.Close()
|
||||
t.Fatalf("startMax: %v", err)
|
||||
}
|
||||
// Remove everything the test creates, or the next pass on the same database
|
||||
// finds this company's scope and attributes its assets to it. Assets first
|
||||
// (they reference the company), then the company; its scope rows cascade.
|
||||
var cid int64
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE id > $1`, startMax)
|
||||
d.Close()
|
||||
defer d.Close()
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id > $1`, startMax); err != nil {
|
||||
t.Errorf("cleanup assets: %v", err)
|
||||
}
|
||||
if cid != 0 {
|
||||
if _, err := d.Exec(`DELETE FROM companies WHERE id = $1`, cid); err != nil {
|
||||
t.Errorf("cleanup company %d: %v", cid, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uniq := startMax + 1
|
||||
root := fmt.Sprintf("scopetest%d.com", uniq)
|
||||
// Unique per run: MAX(assets.id) comes back to the same value after cleanup,
|
||||
// so it cannot tell one pass from the next. The in-scope /24 is picked per
|
||||
// run from 198.18.0.0/15 so a scope left by an interrupted run cannot claim
|
||||
// this run's IP.
|
||||
uniq := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Uint32())
|
||||
n := rand.IntN(512)
|
||||
cidr := fmt.Sprintf("198.%d.%d.0/24", 18+n/256, n%256)
|
||||
root := fmt.Sprintf("scopetest%s.com", uniq)
|
||||
sub := "api." + root
|
||||
ipIn := "198.51.100.9"
|
||||
ipIn := fmt.Sprintf("198.%d.%d.9", 18+n/256, n%256)
|
||||
ipOut := "203.0.113.9"
|
||||
outDomain := fmt.Sprintf("other%d.net", uniq)
|
||||
outDomain := fmt.Sprintf("other%s.net", uniq)
|
||||
|
||||
cid, _, err := cs.UpsertCompany(fmt.Sprintf("ScopeCo %d", uniq), "")
|
||||
cid, _, err = cs.UpsertCompany(fmt.Sprintf("ScopeCo %s", uniq), "")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertCompany: %v", err)
|
||||
}
|
||||
@@ -359,7 +377,7 @@ func TestCompanyScopeAttribution(t *testing.T) {
|
||||
t.Fatalf("pre-scope asset should be unattributed, got %v", *preCompanyID)
|
||||
}
|
||||
|
||||
cs.AddScope(cid, []string{root, "198.51.100.0/24"}, "unit test")
|
||||
cs.AddScope(cid, []string{root, cidr}, "unit test")
|
||||
|
||||
mustCid := func(id int64, want int64, label string) {
|
||||
var cID *int64
|
||||
|
||||
+22
-6
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPoolProfilesOrder pins the failover chain query: keyless profiles can't
|
||||
@@ -17,17 +19,22 @@ func TestPoolProfilesOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Close via Cleanup, registered FIRST so it runs LAST: a plain defer would
|
||||
// close the pool before the row-deleting cleanups below run.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
// llm_profiles.name is unique, so a fixed name collides with a row left on
|
||||
// the same database by an earlier run.
|
||||
sfx := fmt.Sprintf("-%d", time.Now().UnixNano())
|
||||
mk := func(name string, priority int, exclude bool, key string) int64 {
|
||||
id, err := d.SaveProfile(&LLMProfile{
|
||||
Name: name, Format: "openai", Model: "m", APIKey: key,
|
||||
Name: name + sfx, Format: "openai", Model: "m", APIKey: key,
|
||||
Priority: priority, PoolExclude: exclude,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
t.Cleanup(func() { deleteTestProfile(t, d, id) })
|
||||
return id
|
||||
}
|
||||
lo := mk("t-pool-lo", 1, false, "k1")
|
||||
@@ -42,11 +49,11 @@ func TestPoolProfilesOrder(t *testing.T) {
|
||||
var got []int64
|
||||
for _, p := range chain {
|
||||
switch p.Name {
|
||||
case "t-pool-lo", "t-pool-hi":
|
||||
case "t-pool-lo" + sfx, "t-pool-hi" + sfx:
|
||||
got = append(got, p.ID)
|
||||
case "t-pool-excluded":
|
||||
case "t-pool-excluded" + sfx:
|
||||
t.Fatal("pool_exclude profile entered the failover chain")
|
||||
case "t-pool-nokey":
|
||||
case "t-pool-nokey" + sfx:
|
||||
t.Fatal("keyless profile entered the failover chain")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +62,15 @@ func TestPoolProfilesOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// deleteTestProfile removes a profile a test created and reports a failed delete
|
||||
// instead of silently leaving the row for the next run.
|
||||
func deleteTestProfile(t *testing.T, d *DB, id int64) {
|
||||
t.Helper()
|
||||
if _, err := d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id); err != nil {
|
||||
t.Errorf("cleanup profile %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfileContextHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
@@ -11,7 +11,8 @@ func TestConversationPinOrderingAndPatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
first, err := d.CreateConversation("mainagent", fmt.Sprintf("pin-first-%d", suffix), nil)
|
||||
@@ -24,8 +25,8 @@ func TestConversationPinOrderingAndPatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteConversation(first.ID)
|
||||
_ = d.DeleteConversation(second.ID)
|
||||
cleanupErr(t, "first conversation", d.DeleteConversation(first.ID))
|
||||
cleanupErr(t, "second conversation", d.DeleteConversation(second.ID))
|
||||
})
|
||||
|
||||
pinned := true
|
||||
|
||||
@@ -102,12 +102,13 @@ func TestRecordCoverageUpsertHistory(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
task, err := d.CreateTaskWithOptions("coverage ledger", "g", TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
as := d.Assets()
|
||||
store := d.Exploration(task.ExplorationID)
|
||||
for _, vc := range []string{"SQLi", "XSS"} {
|
||||
@@ -158,12 +159,13 @@ func TestCoverageCompletenessBudgetThenReviveDone(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
task, err := d.CreateTaskWithOptions("coverage revive", "g", TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
as, store := d.Assets(), d.Exploration(task.ExplorationID)
|
||||
state := func() string {
|
||||
t.Helper()
|
||||
@@ -221,12 +223,13 @@ func TestRecordCoverageRowCap(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
task, err := d.CreateTaskWithOptions("coverage cap", "g", TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
if _, err := d.Exec(`INSERT INTO coverage_ledger(task_id, surface, risk_area, outcome)
|
||||
SELECT $1, '/s' || g, 'xss', 'needs_follow_up' FROM generate_series(1, $2::int) g`, task.ID, MaxCoverageRowsPerTask); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -12,7 +12,8 @@ func TestCustomToolCRUD(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
key := "ct_test_tool"
|
||||
_ = d.DeleteCustomTool(key) // clean slate
|
||||
@@ -30,7 +31,7 @@ func TestCustomToolCRUD(t *testing.T) {
|
||||
if err := d.CreateCustomTool(in); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteCustomTool(key) })
|
||||
t.Cleanup(func() { cleanupErr(t, "custom tool", d.DeleteCustomTool(key)) })
|
||||
|
||||
got, err := d.GetTool(key)
|
||||
if err != nil || got == nil {
|
||||
|
||||
@@ -10,13 +10,14 @@ func TestInheritedActivityReadsRequireTerminalIntent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
expID, err := d.CreateExploration("source activity boundary", "source activity boundary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) })
|
||||
t.Cleanup(func() { cleanupExec(t, d, `DELETE FROM explorations WHERE id=$1`, expID) })
|
||||
store := d.Exploration(expID)
|
||||
intentID, err := store.AddIntent(map[string]any{"summary": "source work"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
@@ -93,7 +94,8 @@ func TestExplorationDirectSourceReadView(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
grand, err := d.CreateTask("grand source", "grand goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
@@ -112,9 +114,9 @@ func TestExplorationDirectSourceReadView(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(current.ID)
|
||||
_ = d.DeleteTask(source.ID)
|
||||
_ = d.DeleteTask(grand.ID)
|
||||
cleanupErr(t, "current task", d.DeleteTask(current.ID))
|
||||
cleanupErr(t, "source task", d.DeleteTask(source.ID))
|
||||
cleanupErr(t, "grand task", d.DeleteTask(grand.ID))
|
||||
})
|
||||
|
||||
grandStore := d.Exploration(grand.ExplorationID)
|
||||
@@ -315,7 +317,8 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
grand, err := d.CreateTask("grand assets", "grand goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
@@ -351,10 +354,11 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(current.ID)
|
||||
_ = d.DeleteTask(source.ID)
|
||||
_ = d.DeleteTask(grand.ID)
|
||||
_, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID})
|
||||
cleanupErr(t, "current task", d.DeleteTask(current.ID))
|
||||
cleanupErr(t, "source task", d.DeleteTask(source.ID))
|
||||
cleanupErr(t, "grand task", d.DeleteTask(grand.ID))
|
||||
_, err := assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID})
|
||||
cleanupErr(t, "assets", err)
|
||||
})
|
||||
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test", "agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+17
-9
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -14,7 +15,8 @@ func TestProfileRetryRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
want := RetryOverride{
|
||||
Connect: RetryRule{Attempts: 5, IntervalMS: 2000},
|
||||
@@ -22,12 +24,12 @@ func TestProfileRetryRoundTrip(t *testing.T) {
|
||||
Stream: RetryRule{IntervalMS: 250},
|
||||
}
|
||||
id, err := d.SaveProfile(&LLMProfile{
|
||||
Name: "t-retry-roundtrip", Format: "openai", Model: "m", APIKey: "k", Retry: want,
|
||||
Name: fmt.Sprintf("t-retry-roundtrip-%d", time.Now().UnixNano()), Format: "openai", Model: "m", APIKey: "k", Retry: want,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
t.Cleanup(func() { deleteTestProfile(t, d, id) })
|
||||
|
||||
got, err := d.ProfileByID(id)
|
||||
if err != nil || got == nil {
|
||||
@@ -71,16 +73,17 @@ func TestProfileRetryClampedOnSave(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
id, err := d.SaveProfile(&LLMProfile{
|
||||
Name: "t-retry-clamp", Format: "openai", Model: "m", APIKey: "k",
|
||||
Name: fmt.Sprintf("t-retry-clamp-%d", time.Now().UnixNano()), Format: "openai", Model: "m", APIKey: "k",
|
||||
Retry: RetryOverride{Connect: RetryRule{Attempts: -99, IntervalMS: -5}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
t.Cleanup(func() { deleteTestProfile(t, d, id) })
|
||||
got, err := d.ProfileByID(id)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("ProfileByID: %v", err)
|
||||
@@ -97,17 +100,22 @@ func TestLLMRetryPolicyRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the restore below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
before, hadBefore, err := d.GetSetting(settingLLMRetryPolicy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
var err error
|
||||
if hadBefore {
|
||||
d.SetSetting(settingLLMRetryPolicy, before)
|
||||
err = d.SetSetting(settingLLMRetryPolicy, before)
|
||||
} else {
|
||||
d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy)
|
||||
_, err = d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("restore %s: %v", settingLLMRetryPolicy, err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -447,6 +447,14 @@ func restoreArchiveAssets(tx *sql.Tx, taskID int64, raw json.RawMessage) (map[in
|
||||
row["company_source"] = "explicit"
|
||||
warnings = append(warnings, fmt.Sprintf("asset %d's company %d has been deleted; restored as unaffiliated", oldID, companyID))
|
||||
}
|
||||
// Archives taken before normalizeURL stripped userinfo can hold
|
||||
// http://user:pass@host URLs. Normalise like the live upserts so the
|
||||
// restore neither stores the credential nor misses the clean row.
|
||||
if typeName, _ := row["type"].(string); typeName == "service" || typeName == "endpoint" {
|
||||
if rawURL, ok := row["url"].(string); ok {
|
||||
row["url"] = normalizeURL(rawURL)
|
||||
}
|
||||
}
|
||||
if existing, found, err := findArchiveAssetNaturalID(tx, row); err != nil {
|
||||
return nil, nil, err
|
||||
} else if found {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const restoreURLHost = "restore-userinfo.example"
|
||||
|
||||
// bigintArray renders ids as a PostgreSQL array literal for a $n::bigint[] cast.
|
||||
func bigintArray(ids ...int64) string {
|
||||
parts := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
parts[i] = fmt.Sprint(id)
|
||||
}
|
||||
return "{" + strings.Join(parts, ",") + "}"
|
||||
}
|
||||
|
||||
// archivedAssetRows exports live asset rows the way SnapshotTaskArchive does,
|
||||
// so the restore tests start from realistic archive JSON.
|
||||
func archivedAssetRows(t *testing.T, d *DB, ids ...int64) []map[string]any {
|
||||
t.Helper()
|
||||
raw, _, err := queryArchiveRows(d, `SELECT asset.* FROM assets asset WHERE asset.id = ANY($1::bigint[]) ORDER BY asset.id`, bigintArray(ids...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := decodeArchiveRows(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != len(ids) {
|
||||
t.Fatalf("exported %d archive rows, want %d", len(rows), len(ids))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// restoreArchivedAssets runs restoreArchiveAssets on rows in a committed tx.
|
||||
func restoreArchivedAssets(t *testing.T, d *DB, taskID int64, rows []map[string]any) map[int64]int64 {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
mapping, _, err := restoreArchiveAssets(tx, taskID, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
|
||||
func restoreURLCleanup(d *DB) {
|
||||
d.Exec(`DELETE FROM assets WHERE url LIKE '%` + restoreURLHost + `%' OR domain LIKE '%` + restoreURLHost + `' OR root_domain = '` + restoreURLHost + `' OR ip = '198.51.100.231'`) //nolint:errcheck
|
||||
}
|
||||
|
||||
func countRestoreURLRows(t *testing.T, d *DB, assetType, cleanURL string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE type=$1 AND (url=$2 OR url LIKE '%@%' || $3::text || '%')`, assetType, cleanURL, restoreURLHost).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func assertNoRestoreURLCredential(t *testing.T, d *DB, id int64) {
|
||||
t.Helper()
|
||||
var row string
|
||||
if err := d.QueryRow(`SELECT row_to_json(a)::text FROM assets a WHERE id=$1`, id).Scan(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{"alice", "bob", "s3cr3t"} {
|
||||
if strings.Contains(row, secret) {
|
||||
t.Fatalf("restored asset %d still carries %q: %s", id, secret, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsStripsURLUserinfo(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const taskID = int64(880000001)
|
||||
|
||||
svcURL := "http://" + restoreURLHost + ":8080/admin"
|
||||
epURL := "https://api." + restoreURLHost + "/users?id=1"
|
||||
svcID, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: svcURL, TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epID, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: epURL, Method: "GET", TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := archivedAssetRows(t, d, svcID, epID)
|
||||
// Simulate an archive taken before normalizeURL dropped userinfo.
|
||||
rows[0]["url"] = "http://alice:s3cr3t-one@" + restoreURLHost + ":8080/admin"
|
||||
rows[1]["url"] = "https://bob:s3cr3t-two@api." + restoreURLHost + "/users?id=1"
|
||||
archived := map[string]map[string]any{"service": {}, "endpoint": {}}
|
||||
for _, row := range rows {
|
||||
typeName := row["type"].(string)
|
||||
for _, key := range []string{"domain", "root_domain", "port", "service_name", "method"} {
|
||||
archived[typeName][key] = row[key]
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id = ANY($1::bigint[])`, bigintArray(svcID, epID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, taskID, rows)
|
||||
for oldID, wantURL := range map[int64]string{svcID: svcURL, epID: epURL} {
|
||||
newID := mapping[oldID]
|
||||
if newID == 0 {
|
||||
t.Fatalf("asset %d was not restored: %v", oldID, mapping)
|
||||
}
|
||||
var gotURL, typeName string
|
||||
var domain, rootDomain, serviceName, method *string
|
||||
var port *int64
|
||||
if err := d.QueryRow(`SELECT url,type,domain,root_domain,port,service_name,method FROM assets WHERE id=$1`, newID).
|
||||
Scan(&gotURL, &typeName, &domain, &rootDomain, &port, &serviceName, &method); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotURL != wantURL {
|
||||
t.Fatalf("restored %s url=%q, want %q", typeName, gotURL, wantURL)
|
||||
}
|
||||
assertNoRestoreURLCredential(t, d, newID)
|
||||
// Only url is normalised: the other columns keep the archived values.
|
||||
want := archived[typeName]
|
||||
got := map[string]any{"domain": domain, "root_domain": rootDomain, "port": port, "service_name": serviceName, "method": method}
|
||||
for key, value := range got {
|
||||
gotJSON, _ := json.Marshal(value)
|
||||
wantJSON, _ := json.Marshal(want[key])
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("%s %s=%s, want archived %s", typeName, key, gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, typeName, wantURL); n != 1 {
|
||||
t.Fatalf("%s rows for %q = %d, want 1", typeName, wantURL, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsReusesCleanURLRow(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const liveTaskID, archivedTaskID = int64(880000002), int64(880000003)
|
||||
|
||||
svcURL := "http://" + restoreURLHost + ":8080/admin"
|
||||
epURL := "https://api." + restoreURLHost + "/users?id=1"
|
||||
svcID, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: svcURL, TaskID: liveTaskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epID, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: epURL, Method: "GET", TaskID: liveTaskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := archivedAssetRows(t, d, svcID, epID)
|
||||
// The archived rows are other, older rows whose url still carries userinfo.
|
||||
rows[0]["id"], rows[0]["url"] = int64(990000101), "http://alice:s3cr3t-one@"+restoreURLHost+":8080/admin"
|
||||
rows[1]["id"], rows[1]["url"] = int64(990000102), "https://bob:s3cr3t-two@api."+restoreURLHost+"/users?id=1"
|
||||
for _, row := range rows {
|
||||
row["task_ids"] = []any{archivedTaskID}
|
||||
}
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, archivedTaskID, rows)
|
||||
for oldID, existingID := range map[int64]int64{990000101: svcID, 990000102: epID} {
|
||||
if mapping[oldID] != existingID {
|
||||
t.Fatalf("archived asset %d mapped to %d, want existing clean row %d", oldID, mapping[oldID], existingID)
|
||||
}
|
||||
var taskIDs string
|
||||
if err := d.QueryRow(`SELECT task_ids::text FROM assets WHERE id=$1`, existingID).Scan(&taskIDs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := bigintArray(liveTaskID, archivedTaskID); taskIDs != want {
|
||||
t.Fatalf("asset %d task_ids=%s, want %s", existingID, taskIDs, want)
|
||||
}
|
||||
assertNoRestoreURLCredential(t, d, existingID)
|
||||
var leftover int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE id=$1`, oldID).Scan(&leftover); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if leftover != 0 {
|
||||
t.Fatalf("archived asset %d inserted beside the clean row", oldID)
|
||||
}
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, "service", svcURL); n != 1 {
|
||||
t.Fatalf("service rows = %d, want 1", n)
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, "endpoint", epURL); n != 1 {
|
||||
t.Fatalf("endpoint rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsDedupsURLAcrossCredentials(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const taskID = int64(880000004)
|
||||
|
||||
svcURL := "http://" + restoreURLHost + ":8080/admin"
|
||||
svcID, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: svcURL, TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := archivedAssetRows(t, d, svcID)[0]
|
||||
second := archivedAssetRows(t, d, svcID)[0]
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id=$1`, svcID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first["id"], first["url"] = int64(990000201), "http://alice:s3cr3t-one@"+restoreURLHost+":8080/admin"
|
||||
second["id"], second["url"] = int64(990000202), "http://bob:s3cr3t-two@"+restoreURLHost+":8080/admin"
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, taskID, []map[string]any{first, second})
|
||||
if mapping[990000201] == 0 || mapping[990000201] != mapping[990000202] {
|
||||
t.Fatalf("credential variants restored as separate rows: %v", mapping)
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, "service", svcURL); n != 1 {
|
||||
t.Fatalf("service rows = %d, want exactly 1", n)
|
||||
}
|
||||
assertNoRestoreURLCredential(t, d, mapping[990000201])
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsLeavesDomainAndIPURLUntouched(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const taskID = int64(880000005)
|
||||
|
||||
domainID, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: restoreURLHost, TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ipID, err := av2.UpsertIP(UpsertIPReq{IP: "198.51.100.231", TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := archivedAssetRows(t, d, domainID, ipID)
|
||||
// A url normalizeURL would rewrite proves the normalisation is scoped to
|
||||
// service and endpoint rows.
|
||||
const oddURL = "HTTP://Restore-Userinfo.example/"
|
||||
for _, row := range rows {
|
||||
row["url"] = oddURL
|
||||
}
|
||||
want := map[int64][]any{}
|
||||
for _, row := range rows {
|
||||
id, _ := jsonInt64(row["id"])
|
||||
want[id] = []any{row["type"], row["domain"], row["root_domain"], row["ip"]}
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id = ANY($1::bigint[])`, bigintArray(domainID, ipID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, taskID, rows)
|
||||
for oldID, fields := range want {
|
||||
var typeName string
|
||||
var domain, rootDomain, ip, gotURL *string
|
||||
if err := d.QueryRow(`SELECT type,domain,root_domain,ip,url FROM assets WHERE id=$1`, mapping[oldID]).
|
||||
Scan(&typeName, &domain, &rootDomain, &ip, &gotURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := json.Marshal([]any{typeName, domain, rootDomain, ip})
|
||||
wantJSON, _ := json.Marshal(fields)
|
||||
if string(got) != string(wantJSON) {
|
||||
t.Errorf("asset %d restored as %s, want %s", oldID, got, wantJSON)
|
||||
}
|
||||
if gotURL == nil || *gotURL != oddURL {
|
||||
t.Errorf("%s asset url changed to %v, want %q", typeName, gotURL, oddURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-9
@@ -12,13 +12,14 @@ func TestRegisterTaskAssetScopesCreatesAssetsAndPersistsTextScope(t *testing.T)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
task, err := d.CreateTask("manual scope registration", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
suffix := time.Now().UnixNano()
|
||||
domain := fmt.Sprintf("manual-scope-%d.example.test", suffix)
|
||||
ip := fmt.Sprintf("203.0.%d.%d", (suffix/250)%250, suffix%250+1)
|
||||
@@ -48,7 +49,10 @@ func TestRegisterTaskAssetScopesCreatesAssetsAndPersistsTextScope(t *testing.T)
|
||||
t.Fatalf("unexpected task asset provenance: %+v", asset)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs(assetIDs) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.Assets().DeleteByIDs(assetIDs)
|
||||
cleanupErr(t, "assets", err)
|
||||
})
|
||||
scopes, err := d.Assets().ListTaskScope(task.ID)
|
||||
if err != nil || len(scopes) != 5 {
|
||||
t.Fatalf("task scope=%+v err=%v", scopes, err)
|
||||
@@ -91,18 +95,22 @@ func TestTaskAssetAttachDetachPreservesGlobalAssetAndAnchors(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
task, err := d.CreateTask("asset editing", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
assetID, err := d.Assets().UpsertRootDomain(UpsertRootDomainReq{Domain: fmt.Sprintf("manual-%d.example.test", time.Now().UnixNano())})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{assetID}) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.Assets().DeleteByIDs([]int64{assetID})
|
||||
cleanupErr(t, "asset", err)
|
||||
})
|
||||
|
||||
mutation, err := d.Assets().AttachAssetsToTask(task.ID, []int64{assetID, assetID}, "authorized asset list item 3")
|
||||
if err != nil {
|
||||
@@ -144,7 +152,8 @@ func TestIntentAssetsIncludesDirectSourceProvenance(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
source, err := d.CreateTask("source assets", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
@@ -154,7 +163,10 @@ func TestIntentAssetsIncludesDirectSourceProvenance(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(current.ID); _ = d.DeleteTask(source.ID) })
|
||||
t.Cleanup(func() {
|
||||
cleanupErr(t, "current task", d.DeleteTask(current.ID))
|
||||
cleanupErr(t, "source task", d.DeleteTask(source.ID))
|
||||
})
|
||||
|
||||
assetID, err := d.Assets().UpsertHTTPService(UpsertHTTPServiceReq{
|
||||
URL: fmt.Sprintf("https://intent-%d.example.test", time.Now().UnixNano()), TaskID: source.ID,
|
||||
@@ -162,7 +174,10 @@ func TestIntentAssetsIncludesDirectSourceProvenance(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{assetID}) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.Assets().DeleteByIDs([]int64{assetID})
|
||||
cleanupErr(t, "asset", err)
|
||||
})
|
||||
intentID, err := d.Exploration(source.ExplorationID).AddIntent(map[string]any{"summary": "source worker"}, 5, []int64{assetID}, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -13,7 +13,8 @@ func TestTaskCategoryCRUDAndTaskAssignment(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
name := fmt.Sprintf("Category %d", time.Now().UnixNano())
|
||||
category, err := d.CreateTaskCategory(" " + strings.ReplaceAll(name, " ", " ") + " ")
|
||||
@@ -31,7 +32,7 @@ func TestTaskCategoryCRUDAndTaskAssignment(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
loaded, err := d.GetTask(task.ID)
|
||||
if err != nil || loaded == nil || loaded.CategoryID == nil || *loaded.CategoryID != category.ID || loaded.CategoryName != name {
|
||||
t.Fatalf("categorized task=%+v err=%v", loaded, err)
|
||||
@@ -95,14 +96,18 @@ func TestSetTasksCategoryBatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
category, err := d.CreateTaskCategory(fmt.Sprintf("Batch %d", stamp))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.DeleteTaskCategory(category.ID) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.DeleteTaskCategory(category.ID)
|
||||
cleanupErr(t, "category", err)
|
||||
})
|
||||
|
||||
ids := make([]int64, 0, 3)
|
||||
for i := range 3 {
|
||||
@@ -110,7 +115,7 @@ func TestSetTasksCategoryBatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
ids = append(ids, task.ID)
|
||||
}
|
||||
|
||||
@@ -152,14 +157,15 @@ func TestSetTasksCategoryRejectsBadInputAtomically(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
task, err := d.CreateTaskWithOptions(fmt.Sprintf("batch-reject-%d", stamp), "goal", TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
|
||||
missingCategory := int64(1 << 62)
|
||||
if _, _, err := d.SetTasksCategory([]int64{task.ID}, &missingCategory); !errors.Is(err, ErrTaskCategoryNotFound) {
|
||||
|
||||
@@ -13,7 +13,8 @@ func TestTaskLLMProfileMutationsLockTaskBeforeProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
first, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -38,9 +39,9 @@ func TestTaskLLMProfileMutationsLockTaskBeforeProfile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
_ = d.DeleteProfile(first)
|
||||
_ = d.DeleteProfile(second)
|
||||
cleanupErr(t, "task", d.DeleteTask(task.ID))
|
||||
cleanupProfile(t, d, first)
|
||||
cleanupProfile(t, d, second)
|
||||
})
|
||||
|
||||
replaceDB, replacePID := openSingleConnectionTestDB(t, dsn)
|
||||
@@ -112,7 +113,8 @@ func TestDeleteProfileLocksNonTaskReferencesBeforeProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -132,8 +134,8 @@ func TestDeleteProfileLocksNonTaskReferencesBeforeProfile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteAgent(agent.Key)
|
||||
_ = d.DeleteProfile(profileID)
|
||||
cleanupErr(t, "agent", d.DeleteAgent(agent.Key))
|
||||
cleanupProfile(t, d, profileID)
|
||||
})
|
||||
|
||||
blocker, err := d.Begin()
|
||||
@@ -169,7 +171,8 @@ func TestDeleteProfileLocksNonTaskReferencesBeforeProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -184,8 +187,8 @@ func TestDeleteProfileLocksNonTaskReferencesBeforeProfile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteConversation(conversation.ID)
|
||||
_ = d.DeleteProfile(profileID)
|
||||
cleanupErr(t, "conversation", d.DeleteConversation(conversation.ID))
|
||||
cleanupProfile(t, d, profileID)
|
||||
})
|
||||
|
||||
blocker, err := d.Begin()
|
||||
@@ -225,7 +228,8 @@ func TestNonTaskProfileMutationsLockReferenceBeforeProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -240,8 +244,8 @@ func TestNonTaskProfileMutationsLockReferenceBeforeProfile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteAgent(agent.Key)
|
||||
_ = d.DeleteProfile(profileID)
|
||||
cleanupErr(t, "agent", d.DeleteAgent(agent.Key))
|
||||
cleanupProfile(t, d, profileID)
|
||||
})
|
||||
|
||||
blocker, err := d.Begin()
|
||||
@@ -273,7 +277,8 @@ func TestNonTaskProfileMutationsLockReferenceBeforeProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -288,8 +293,8 @@ func TestNonTaskProfileMutationsLockReferenceBeforeProfile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteConversation(conversation.ID)
|
||||
_ = d.DeleteProfile(profileID)
|
||||
cleanupErr(t, "conversation", d.DeleteConversation(conversation.ID))
|
||||
cleanupProfile(t, d, profileID)
|
||||
})
|
||||
|
||||
blocker, err := d.Begin()
|
||||
@@ -323,7 +328,8 @@ func TestCreateConversationAndDeleteProfileDoNotDeadlock(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -334,8 +340,8 @@ func TestCreateConversationAndDeleteProfileDoNotDeadlock(t *testing.T) {
|
||||
}
|
||||
title := fmt.Sprintf("conversation create race %d", suffix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM conversations WHERE title=$1`, title)
|
||||
_ = d.DeleteProfile(profileID)
|
||||
cleanupExec(t, d, `DELETE FROM conversations WHERE title=$1`, title)
|
||||
cleanupProfile(t, d, profileID)
|
||||
})
|
||||
|
||||
profileBlocker, err := d.Begin()
|
||||
@@ -403,7 +409,8 @@ func TestDeleteProfileRetriesReferenceCommittedAfterInitialScan(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -418,8 +425,8 @@ func TestDeleteProfileRetriesReferenceCommittedAfterInitialScan(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
_ = d.DeleteProfile(profileID)
|
||||
cleanupErr(t, "task", d.DeleteTask(task.ID))
|
||||
cleanupProfile(t, d, profileID)
|
||||
})
|
||||
|
||||
// Keep the new reference uncommitted while deletion takes its initial
|
||||
|
||||
@@ -11,7 +11,8 @@ func TestListTasksBulkHydratesTaskContext(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
profileID, err := d.SaveProfile(&LLMProfile{
|
||||
@@ -20,17 +21,17 @@ func TestListTasksBulkHydratesTaskContext(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteProfile(profileID) })
|
||||
t.Cleanup(func() { cleanupProfile(t, d, profileID) })
|
||||
source, err := d.CreateTask(fmt.Sprintf("bulk-list-source-%d", stamp), "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(source.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "source task", d.DeleteTask(source.ID)) })
|
||||
companyID, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Bulk List Company %d", stamp), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Companies().DeleteCompany(companyID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "company", d.Companies().DeleteCompany(companyID)) })
|
||||
child, err := d.CreateTaskWithOptions("bulk-list-child", "goal", TaskCreateOptions{
|
||||
LLMProfileIDs: []int64{profileID},
|
||||
SourceTaskIDs: []int64{source.ID},
|
||||
@@ -39,7 +40,7 @@ func TestListTasksBulkHydratesTaskContext(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(child.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "child task", d.DeleteTask(child.ID)) })
|
||||
|
||||
tasks, err := d.ListTasks()
|
||||
if err != nil {
|
||||
@@ -72,13 +73,14 @@ func TestTaskListMetricsAll(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
task, err := d.CreateTask("task-list-metrics", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "task", d.DeleteTask(task.ID)) })
|
||||
|
||||
resultAt := time.Now().Add(-time.Second).Truncate(time.Second)
|
||||
latestAt := resultAt.Add(time.Second)
|
||||
|
||||
@@ -14,7 +14,8 @@ func TestTaskTemplateCRUDAndNormalizedUniqueness(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
name := fmt.Sprintf("Template %d", suffix)
|
||||
@@ -26,7 +27,10 @@ func TestTaskTemplateCRUDAndNormalizedUniqueness(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(created.ID) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.DeleteTaskTemplate(created.ID)
|
||||
cleanupErr(t, "template", err)
|
||||
})
|
||||
if created.Name != name || created.Description != "initial description" || created.Goal != "initial goal" {
|
||||
t.Fatalf("template was not normalized: %+v", created)
|
||||
}
|
||||
@@ -85,7 +89,8 @@ func TestTaskTemplateDisjointPatchesCompose(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanup below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
created, err := d.CreateTaskTemplate(TaskTemplateInput{
|
||||
Name: fmt.Sprintf("Concurrent template %d", time.Now().UnixNano()),
|
||||
@@ -95,7 +100,10 @@ func TestTaskTemplateDisjointPatchesCompose(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(created.ID) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.DeleteTaskTemplate(created.ID)
|
||||
cleanupErr(t, "template", err)
|
||||
})
|
||||
|
||||
description := "description from concurrent patch"
|
||||
goal := "goal from concurrent patch"
|
||||
|
||||
+30
-21
@@ -13,7 +13,7 @@ func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
tk, err := d.CreateTask("migration test", "goal X", nil, 0, 0)
|
||||
if err != nil {
|
||||
@@ -87,7 +87,8 @@ func TestTaskDeleteCascadeAssets(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
first, err := d.CreateTask("cascade delete test", "goal A", nil, 0, 0)
|
||||
if err != nil {
|
||||
@@ -138,9 +139,10 @@ func TestTaskDeleteCascadeAssets(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(first.ID)
|
||||
_ = d.DeleteTask(second.ID)
|
||||
_, _ = assets.DeleteByIDs([]int64{exclusiveID, sharedID, anchorOnlyID, protectedID})
|
||||
cleanupErr(t, "first task", d.DeleteTask(first.ID))
|
||||
cleanupErr(t, "second task", d.DeleteTask(second.ID))
|
||||
_, err := assets.DeleteByIDs([]int64{exclusiveID, sharedID, anchorOnlyID, protectedID})
|
||||
cleanupErr(t, "assets", err)
|
||||
})
|
||||
|
||||
hosts, err := assets.HostsByTask(first.ID)
|
||||
@@ -211,7 +213,8 @@ func TestTaskRelationsAndLLMFailoverChain(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
profileIDs := make([]int64, 0, 3)
|
||||
@@ -242,11 +245,11 @@ func TestTaskRelationsAndLLMFailoverChain(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(child.ID)
|
||||
_ = d.DeleteTask(sourceA.ID)
|
||||
_ = d.DeleteTask(sourceB.ID)
|
||||
cleanupErr(t, "child task", d.DeleteTask(child.ID))
|
||||
cleanupErr(t, "source task A", d.DeleteTask(sourceA.ID))
|
||||
cleanupErr(t, "source task B", d.DeleteTask(sourceB.ID))
|
||||
for _, id := range profileIDs {
|
||||
_ = d.DeleteProfile(id)
|
||||
cleanupProfile(t, d, id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -469,13 +472,14 @@ func TestTaskContextRejectsDuplicatesAndAllowsTerminalLLMEdits(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
source, err := d.CreateTask("source", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(source.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "source task", d.DeleteTask(source.ID)) })
|
||||
if _, err := d.CreateTaskWithOptions("bad", "goal", TaskCreateOptions{SourceTaskIDs: []int64{source.ID, source.ID}}); err == nil {
|
||||
t.Fatal("duplicate source task ids should be rejected")
|
||||
}
|
||||
@@ -490,13 +494,13 @@ func TestTaskContextRejectsDuplicatesAndAllowsTerminalLLMEdits(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteProfile(profileID) })
|
||||
t.Cleanup(func() { cleanupProfile(t, d, profileID) })
|
||||
|
||||
task, err := d.CreateTask("terminal", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "terminal task", d.DeleteTask(task.ID)) })
|
||||
if err := d.SetStatus(task.ID, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -523,7 +527,8 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
companyA, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Task Company A %d", suffix), "")
|
||||
@@ -539,14 +544,14 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2,$3)`, companyA, companyB, emptyCompany)
|
||||
cleanupExec(t, d, `DELETE FROM companies WHERE id IN ($1,$2,$3)`, companyA, companyB, emptyCompany)
|
||||
})
|
||||
|
||||
existingTask, err := d.CreateTask("existing company asset owner", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(existingTask.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "existing task", d.DeleteTask(existingTask.ID)) })
|
||||
var companyAssetA, companyAssetB int64
|
||||
domainA := fmt.Sprintf("task-company-a-%d.example.test", suffix)
|
||||
domainB := fmt.Sprintf("task-company-b-%d.example.test", suffix)
|
||||
@@ -562,7 +567,10 @@ VALUES ('root_domain',$1,$1,$2,'explicit')
|
||||
RETURNING id`, domainB, companyB).Scan(&companyAssetB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{companyAssetA, companyAssetB}) })
|
||||
t.Cleanup(func() {
|
||||
_, err := d.Assets().DeleteByIDs([]int64{companyAssetA, companyAssetB})
|
||||
cleanupErr(t, "company assets", err)
|
||||
})
|
||||
|
||||
task, err := d.CreateTaskWithOptions("company-scoped task", "use company scope", TaskCreateOptions{
|
||||
CompanyIDs: []int64{companyA, companyB},
|
||||
@@ -570,7 +578,7 @@ RETURNING id`, domainB, companyB).Scan(&companyAssetB); err != nil {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "company-scoped task", d.DeleteTask(task.ID)) })
|
||||
if fmt.Sprint(task.CompanyIDs) != fmt.Sprint([]int64{companyA, companyB}) {
|
||||
t.Fatalf("creation result company IDs=%v", task.CompanyIDs)
|
||||
}
|
||||
@@ -674,7 +682,8 @@ func TestListTasksOrderByIDDesc(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// Registered FIRST so it runs LAST, after the row cleanups below.
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
var ids []int64
|
||||
@@ -683,7 +692,7 @@ func TestListTasksOrderByIDDesc(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(tk.ID) })
|
||||
t.Cleanup(func() { cleanupErr(t, "order task", d.DeleteTask(tk.ID)) })
|
||||
ids = append(ids, tk.ID)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user