This commit is contained in:
+34
-4
@@ -1311,8 +1311,24 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
else:seen.add(key);accepted.append(b)
|
else:seen.add(key);accepted.append(b)
|
||||||
return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted})
|
return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted})
|
||||||
def list_sources(self,db,org):
|
def list_sources(self,db,org):
|
||||||
cols='id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at'
|
cols='id,organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at'
|
||||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute(f"SELECT {cols} FROM sources WHERE organization_id=? ORDER BY id",(org,))]})
|
items=[]
|
||||||
|
adapter_meta={a["source_code"]:a for a in available_adapters()}
|
||||||
|
for raw in db.execute(f"SELECT {cols} FROM sources WHERE organization_id=? ORDER BY id",(org,)):
|
||||||
|
item=row_json(raw); meta=adapter_meta.get(item.get("source_code") or item.get("kind"), {})
|
||||||
|
try: config=json.loads(raw["config_json"] or "{}")
|
||||||
|
except (TypeError,ValueError): config={}
|
||||||
|
try: policy=json.loads(raw["policy_json"] or "{}")
|
||||||
|
except (TypeError,ValueError): policy={}
|
||||||
|
item.update({"available": bool(meta.get("available", False)), "optional": bool(meta.get("optional", False)),
|
||||||
|
"configured": bool(meta.get("available", False) and (config.get("csv") or config.get("rows") is not None or raw["approved"])),
|
||||||
|
"credential_status": "Not required" if not meta.get("requires_credentials") else "Required / not configured",
|
||||||
|
"terms_status": "Provided" if policy.get("terms_url") or config.get("terms_url") else "Not reviewed",
|
||||||
|
"owner": policy.get("owner") or config.get("owner") or "Not assigned",
|
||||||
|
"rate_limit": policy.get("rate_limit") or config.get("rate_limit") or "Not set"})
|
||||||
|
item.pop("config_json", None)
|
||||||
|
items.append(item)
|
||||||
|
return self.send_json(200,{"organization_id":org,"items":items})
|
||||||
def list_queries(self,db,org):
|
def list_queries(self,db,org):
|
||||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute("SELECT * FROM discovery_queries WHERE organization_id=? ORDER BY id",(org,))]})
|
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute("SELECT * FROM discovery_queries WHERE organization_id=? ORDER BY id",(org,))]})
|
||||||
def list_source_records(self,db,org,q):
|
def list_source_records(self,db,org,q):
|
||||||
@@ -1342,9 +1358,23 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_source"})
|
except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_source"})
|
||||||
self.audit(db,user,'source.created',str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone()))
|
self.audit(db,user,'source.created',str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone()))
|
||||||
def update_source(self,sid,payload,db,user):
|
def update_source(self,sid,payload,db,user):
|
||||||
if not db.execute("SELECT id FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone():return self.send_json(404,{"error":"not_found"})
|
source=db.execute("SELECT * FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone()
|
||||||
|
if not source:return self.send_json(404,{"error":"not_found"})
|
||||||
if 'enabled' not in payload:return self.send_json(400,{"error":"enabled_required"})
|
if 'enabled' not in payload:return self.send_json(400,{"error":"enabled_required"})
|
||||||
value=int(bool(payload['enabled']));db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
|
value=int(bool(payload['enabled']))
|
||||||
|
if value:
|
||||||
|
adapter=adapter_for(source['kind'])
|
||||||
|
try: config=json.loads(source['config_json'] or '{}')
|
||||||
|
except (TypeError,ValueError): config={}
|
||||||
|
metadata=next((item for item in available_adapters() if item['source_code']==source['kind']), {})
|
||||||
|
if not metadata.get('available', False): return self.send_json(409,{"error":"source_unavailable"})
|
||||||
|
validation=adapter.validate_config(config)
|
||||||
|
# A blank manual source is a deliberate staging point: the query or
|
||||||
|
# ingest payload can provide rows later. Other adapters must be ready
|
||||||
|
# before they are enabled.
|
||||||
|
if not validation.valid and not (source['kind'] == 'manual' and not config):
|
||||||
|
return self.send_json(409,{"error":"source_not_configured","details":validation.errors})
|
||||||
|
db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
|
||||||
def create_query(self,payload,db,user):
|
def create_query(self,payload,db,user):
|
||||||
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
|
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
|
||||||
selected=payload.get('selected_adapters',payload.get('sources',[])); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
|
selected=payload.get('selected_adapters',payload.get('sources',[])); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
|
||||||
|
|||||||
+151
-8
@@ -7,7 +7,14 @@ Adapters never emit or persist credential values.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Mapping, Protocol, Sequence
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
import csv, io, random, time
|
import csv, io, random, time, json
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .website_scanner import validate_url
|
||||||
|
except ImportError:
|
||||||
|
from website_scanner import validate_url
|
||||||
|
|
||||||
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "private_key", "credential"}
|
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "private_key", "credential"}
|
||||||
NETWORK_KINDS = {"google_places", "bing_local", "approved_directory", "public_website", "permitted_social", "ct_logs", "dns", "rdap"}
|
NETWORK_KINDS = {"google_places", "bing_local", "approved_directory", "public_website", "permitted_social", "ct_logs", "dns", "rdap"}
|
||||||
@@ -104,6 +111,9 @@ class _Base:
|
|||||||
kind = ""
|
kind = ""
|
||||||
source_code = ""
|
source_code = ""
|
||||||
display_name = ""
|
display_name = ""
|
||||||
|
available = True
|
||||||
|
optional = False
|
||||||
|
requires_credentials = False
|
||||||
def validate_config(self, config):
|
def validate_config(self, config):
|
||||||
if not isinstance(config, Mapping): return ValidationResult(False, ["config must be an object"])
|
if not isinstance(config, Mapping): return ValidationResult(False, ["config must be an object"])
|
||||||
found = contains_secret(config)
|
found = contains_secret(config)
|
||||||
@@ -145,9 +155,142 @@ class CsvSource(_Base):
|
|||||||
result = self.validate_config(config)
|
result = self.validate_config(config)
|
||||||
if not result.valid: raise ValueError(result.errors[0])
|
if not result.valid: raise ValueError(result.errors[0])
|
||||||
reader = csv.DictReader(io.StringIO(config["csv"].replace("\r\n", "\n")))
|
reader = csv.DictReader(io.StringIO(config["csv"].replace("\r\n", "\n")))
|
||||||
return DiscoveryPage([normalize_record({str(k).strip().lower():v for k,v in row.items()}) for row in reader], metadata={"adapter":self.source_code,"columns":reader.fieldnames or []})
|
records = []
|
||||||
|
for row in reader:
|
||||||
|
normalized = {str(k).strip().lower(): v for k, v in row.items()}
|
||||||
|
if any(str(v or '').strip() for v in normalized.values()):
|
||||||
|
records.append(normalize_record(normalized))
|
||||||
|
return DiscoveryPage(records, metadata={"adapter":self.source_code,"columns":reader.fieldnames or [],"record_count":len(records)})
|
||||||
|
|
||||||
|
|
||||||
|
class _HttpJsonSource(_Base):
|
||||||
|
"""Small, bounded JSON client used only for public standards-based sources."""
|
||||||
|
max_bytes = 256 * 1024
|
||||||
|
timeout = 8
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
safe = validate_url(url)
|
||||||
|
request = Request(safe, headers={"User-Agent": "ProspectOS/0.1 (+public-source-research)"})
|
||||||
|
with urlopen(request, timeout=self.timeout) as response:
|
||||||
|
body = response.read(self.max_bytes + 1)
|
||||||
|
if len(body) > self.max_bytes:
|
||||||
|
raise ValueError("source_response_too_large")
|
||||||
|
return json.loads(body.decode("utf-8", "replace")), safe
|
||||||
|
|
||||||
|
|
||||||
|
class PublicWebsiteSource(_Base):
|
||||||
|
kind = source_code = "public_website"
|
||||||
|
display_name = "Public website"
|
||||||
|
|
||||||
|
def validate_config(self, config):
|
||||||
|
result = super().validate_config(config)
|
||||||
|
if not result.valid: return result
|
||||||
|
urls = config.get("urls", config.get("url", []))
|
||||||
|
if isinstance(urls, str): urls = [urls] if urls.strip() else []
|
||||||
|
if not isinstance(urls, list) or not urls or len(urls) > 50:
|
||||||
|
return ValidationResult(False, ["urls must contain 1 to 50 public HTTP(S) URLs"])
|
||||||
|
for value in urls:
|
||||||
|
try: validate_url(str(value))
|
||||||
|
except ValueError: return ValidationResult(False, ["unsafe public website URL"])
|
||||||
|
return ValidationResult(True)
|
||||||
|
|
||||||
|
def discover(self, config, cursor=None):
|
||||||
|
result = self.validate_config(config)
|
||||||
|
if not result.valid: raise ValueError(result.errors[0])
|
||||||
|
urls = config.get("urls", config.get("url"))
|
||||||
|
if isinstance(urls, str): urls = [urls]
|
||||||
|
records = []
|
||||||
|
for raw_url in urls[:50]:
|
||||||
|
safe = validate_url(str(raw_url))
|
||||||
|
request = Request(safe, headers={"User-Agent": "ProspectOS/0.1 (+public-source-research)"})
|
||||||
|
with urlopen(request, timeout=8) as response:
|
||||||
|
body = response.read(128 * 1024).decode("utf-8", "replace")
|
||||||
|
final_url = response.geturl()
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
parser = HTMLParser()
|
||||||
|
title = urlparse(final_url).hostname or safe
|
||||||
|
records.append(normalize_record({"name": title, "website": final_url, "description": body[:1000]}))
|
||||||
|
return DiscoveryPage(records, metadata={"adapter": self.source_code, "record_count": len(records), "bounded": True})
|
||||||
|
|
||||||
|
|
||||||
|
class CtLogsSource(_HttpJsonSource):
|
||||||
|
kind = source_code = "ct_logs"
|
||||||
|
display_name = "Certificate transparency logs"
|
||||||
|
|
||||||
|
def validate_config(self, config):
|
||||||
|
result = super().validate_config(config)
|
||||||
|
if not result.valid: return result
|
||||||
|
query = str(config.get("domain", config.get("query", ""))).strip()
|
||||||
|
if not query or len(query) > 253 or any(ch in query for ch in "\r\n"):
|
||||||
|
return ValidationResult(False, ["domain or query is required"])
|
||||||
|
return ValidationResult(True)
|
||||||
|
|
||||||
|
def discover(self, config, cursor=None):
|
||||||
|
result = self.validate_config(config)
|
||||||
|
if not result.valid: raise ValueError(result.errors[0])
|
||||||
|
query = str(config.get("domain", config.get("query"))).strip()
|
||||||
|
endpoint = "https://crt.sh/?" + urlencode({"q": "%25." + query.lstrip("%.") if not query.startswith("%") else query, "output": "json"})
|
||||||
|
payload, source_url = self._get_json(endpoint)
|
||||||
|
if not isinstance(payload, list): raise ValueError("invalid_ct_response")
|
||||||
|
records, seen = [], set()
|
||||||
|
for item in payload[:500]:
|
||||||
|
names = str(item.get("name_value", "")) if isinstance(item, dict) else ""
|
||||||
|
for name in names.splitlines():
|
||||||
|
name = name.strip().lower().lstrip("*.")
|
||||||
|
if not name or name in seen or "." not in name: continue
|
||||||
|
seen.add(name); records.append(normalize_record({"name": name, "website": "https://" + name}))
|
||||||
|
return DiscoveryPage(records[:100], metadata={"adapter": self.source_code, "source_url": source_url, "record_count": len(records), "signal_only": True})
|
||||||
|
|
||||||
|
|
||||||
|
class DnsSource(_Base):
|
||||||
|
kind = source_code = "dns"
|
||||||
|
display_name = "DNS"
|
||||||
|
|
||||||
|
def validate_config(self, config):
|
||||||
|
result = super().validate_config(config)
|
||||||
|
if not result.valid: return result
|
||||||
|
domains = config.get("domains", config.get("domain", []))
|
||||||
|
if isinstance(domains, str): domains = [domains] if domains.strip() else []
|
||||||
|
if not isinstance(domains, list) or not domains or len(domains) > 100: return ValidationResult(False, ["domains must contain 1 to 100 names"])
|
||||||
|
return ValidationResult(True)
|
||||||
|
|
||||||
|
def discover(self, config, cursor=None):
|
||||||
|
result = self.validate_config(config)
|
||||||
|
if not result.valid: raise ValueError(result.errors[0])
|
||||||
|
import socket
|
||||||
|
domains = config.get("domains", config.get("domain")); domains = [domains] if isinstance(domains, str) else domains
|
||||||
|
records = []
|
||||||
|
for domain in domains[:100]:
|
||||||
|
domain = str(domain).strip().lower().rstrip(".")
|
||||||
|
if not domain or "." not in domain: continue
|
||||||
|
try: addresses = sorted({item[4][0] for item in socket.getaddrinfo(domain, 443, type=socket.SOCK_STREAM)})
|
||||||
|
except socket.gaierror: addresses = []
|
||||||
|
records.append(normalize_record({"name": domain, "website": "https://" + domain, "description": json.dumps({"a_aaaa": addresses})}))
|
||||||
|
return DiscoveryPage(records, metadata={"adapter": self.source_code, "record_count": len(records), "dns_status_only": True})
|
||||||
|
|
||||||
|
|
||||||
|
class RdapSource(_HttpJsonSource):
|
||||||
|
kind = source_code = "rdap"
|
||||||
|
display_name = "RDAP"
|
||||||
|
|
||||||
|
def validate_config(self, config):
|
||||||
|
result = super().validate_config(config)
|
||||||
|
if not result.valid: return result
|
||||||
|
domain = str(config.get("domain", "")).strip()
|
||||||
|
if not domain or "." not in domain: return ValidationResult(False, ["domain is required"])
|
||||||
|
return ValidationResult(True)
|
||||||
|
|
||||||
|
def discover(self, config, cursor=None):
|
||||||
|
result = self.validate_config(config)
|
||||||
|
if not result.valid: raise ValueError(result.errors[0])
|
||||||
|
domain = str(config["domain"]).strip().lower().rstrip(".")
|
||||||
|
payload, source_url = self._get_json("https://rdap.org/domain/" + domain)
|
||||||
|
return DiscoveryPage([normalize_record({"name": domain, "website": "https://" + domain, "description": json.dumps({"rdap": payload}, default=str)[:1000]})], metadata={"adapter": self.source_code, "source_url": source_url, "record_count": 1, "registration_signal_only": True})
|
||||||
|
|
||||||
class GatedSource(_Base):
|
class GatedSource(_Base):
|
||||||
|
available = False
|
||||||
|
optional = True
|
||||||
|
requires_credentials = True
|
||||||
required = "approved"
|
required = "approved"
|
||||||
def validate_config(self, config):
|
def validate_config(self, config):
|
||||||
result = super().validate_config(config)
|
result = super().validate_config(config)
|
||||||
@@ -172,11 +315,7 @@ def _gated(code, name):
|
|||||||
GooglePlacesSource = _gated("google_places", "Google Places")
|
GooglePlacesSource = _gated("google_places", "Google Places")
|
||||||
BingLocalSource = _gated("bing_local", "Bing / approved local API")
|
BingLocalSource = _gated("bing_local", "Bing / approved local API")
|
||||||
ApprovedDirectorySource = _gated("approved_directory", "Approved directory")
|
ApprovedDirectorySource = _gated("approved_directory", "Approved directory")
|
||||||
PublicWebsiteSource = _gated("public_website", "Public website")
|
|
||||||
PermittedSocialSource = _gated("permitted_social", "Permitted social")
|
PermittedSocialSource = _gated("permitted_social", "Permitted social")
|
||||||
CtLogsSource = _gated("ct_logs", "Certificate transparency logs")
|
|
||||||
DnsSource = _gated("dns", "DNS")
|
|
||||||
RdapSource = _gated("rdap", "RDAP")
|
|
||||||
|
|
||||||
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, BingLocalSource, ApprovedDirectorySource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
|
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, BingLocalSource, ApprovedDirectorySource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
|
||||||
# common aliases used by clients
|
# common aliases used by clients
|
||||||
@@ -186,5 +325,9 @@ def adapter_for(kind: str) -> DiscoverySource:
|
|||||||
try: return ADAPTERS[str(kind).strip().lower()]()
|
try: return ADAPTERS[str(kind).strip().lower()]()
|
||||||
except KeyError: raise ValueError("unsupported source kind")
|
except KeyError: raise ValueError("unsupported source kind")
|
||||||
|
|
||||||
def available_adapters() -> list[dict[str, str]]:
|
def available_adapters() -> list[dict[str, object]]:
|
||||||
return [{"source_code": cls.source_code, "display_name": cls.display_name} for cls in ADAPTERS.values()]
|
return [{"source_code": cls.source_code, "display_name": cls.display_name,
|
||||||
|
"available": bool(getattr(cls, "available", False)),
|
||||||
|
"optional": bool(getattr(cls, "optional", False)),
|
||||||
|
"requires_credentials": bool(getattr(cls, "requires_credentials", False))}
|
||||||
|
for cls in ADAPTERS.values()]
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import json, os, sqlite3, threading, unittest
|
|||||||
from http.client import HTTPConnection
|
from http.client import HTTPConnection
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from app.main import create_server
|
from app.main import create_server
|
||||||
from app.sources import CsvSource, ManualSource
|
from app.sources import CsvSource, ManualSource, available_adapters
|
||||||
|
|
||||||
class SourceAdapterTests(unittest.TestCase):
|
class SourceAdapterTests(unittest.TestCase):
|
||||||
|
def test_adapter_catalog_exposes_ready_and_gated_sources(self):
|
||||||
|
catalog = {item['source_code']: item for item in available_adapters()}
|
||||||
|
for code in ('manual', 'csv', 'public_website', 'ct_logs', 'dns', 'rdap'):
|
||||||
|
self.assertTrue(catalog[code]['available'], code)
|
||||||
|
for code in ('google_places', 'bing_local', 'approved_directory', 'permitted_social'):
|
||||||
|
self.assertFalse(catalog[code]['available'], code)
|
||||||
|
self.assertTrue(catalog[code]['optional'], code)
|
||||||
|
|
||||||
def test_csv_adapter_is_deterministic_and_normalizes(self):
|
def test_csv_adapter_is_deterministic_and_normalizes(self):
|
||||||
src = CsvSource()
|
src = CsvSource()
|
||||||
a = src.discover({'csv': 'Name,Website,Email\n Acme ,https://acme.test,a@acme.test\n'})
|
a = src.discover({'csv': 'Name,Website,Email\n Acme ,https://acme.test,a@acme.test\n'})
|
||||||
@@ -58,6 +66,14 @@ class SourceApiTests(unittest.TestCase):
|
|||||||
def test_sources_require_auth(self):
|
def test_sources_require_auth(self):
|
||||||
self.cookie=None; self.assertEqual(self.req('GET','/api/v1/sources',cookie=False)[0],401)
|
self.cookie=None; self.assertEqual(self.req('GET','/api/v1/sources',cookie=False)[0],401)
|
||||||
|
|
||||||
|
def test_unavailable_or_unconfigured_sources_cannot_be_enabled(self):
|
||||||
|
status, gated = self.req('POST', '/api/v1/sources', {'name': 'Google', 'kind': 'google_places', 'config': {}})
|
||||||
|
self.assertEqual(status, 201)
|
||||||
|
self.assertEqual(self.req('PATCH', f"/api/v1/sources/{gated['id']}", {'enabled': True})[0], 409)
|
||||||
|
status, website = self.req('POST', '/api/v1/sources', {'name': 'Web', 'kind': 'public_website', 'config': {}})
|
||||||
|
self.assertEqual(status, 201)
|
||||||
|
self.assertEqual(self.req('PATCH', f"/api/v1/sources/{website['id']}", {'enabled': True})[0], 409)
|
||||||
|
|
||||||
def test_fresh_schema_accepts_optional_source_kind_fail_closed(self):
|
def test_fresh_schema_accepts_optional_source_kind_fail_closed(self):
|
||||||
status, source = self.req('POST', '/api/v1/sources', {'name': 'RDAP', 'kind': 'rdap', 'config': {}})
|
status, source = self.req('POST', '/api/v1/sources', {'name': 'RDAP', 'kind': 'rdap', 'config': {}})
|
||||||
self.assertEqual(status, 201)
|
self.assertEqual(status, 201)
|
||||||
|
|||||||
+1
-1
@@ -282,7 +282,7 @@
|
|||||||
function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.filter(source=>!source.optional).map(s => `<option value="${esc(s.id)}" ${sourceState(s)?'':'disabled'}>${esc(sourceLabel(s))} · ${esc(sourceStatus(s))}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))} · ${esc(sourceType(s))}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; }
|
function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.filter(source=>!source.optional).map(s => `<option value="${esc(s.id)}" ${sourceState(s)?'':'disabled'}>${esc(sourceLabel(s))} · ${esc(sourceStatus(s))}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))} · ${esc(sourceType(s))}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; }
|
||||||
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No registered or available sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `<article class="source-row ${source.optional?'source-optional':''}" data-source-id="${esc(source.id||sourceType(source))}"><div class="source-row-main"><div class="source-title-line"><strong>${esc(sourceLabel(source))}</strong><span class="source-type">${esc(sourceType(source))}</span></div><small>${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}</small></div><span class="source-status ${status}">${esc(status==='unavailable'?'Unavailable':status)}</span><dl class="source-facts source-registry-facts"><div><dt>Integration</dt><dd>${esc(source.optional?'Optional adapter':'Registered')}</dd></div><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Available</dt><dd>${available?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${sourceState(source)?'Yes':'No'}</dd></div><div><dt>API credential</dt><dd>${esc(credential)}</dd></div><div><dt>Terms</dt><dd>${esc(terms)}</dd></div><div><dt>Owner</dt><dd>${esc(owner)}</dd></div><div><dt>Rate limit</dt><dd>${esc(rate)}</dd></div><div><dt>Daily quota</dt><dd>${esc(daily)}</dd></div><div><dt>Last health</dt><dd>${esc(lastHealth)} · ${esc(health)}</dd></div><div><dt>Success / error</dt><dd>${esc(success)}<br>${esc(error)}</dd></div><div><dt>Circuit</dt><dd>${esc(circuit)} · ${esc(failures)} failures</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="review" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Review</button><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id||'')}" ${source.optional||!available?'disabled':''}>${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
|
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No registered or available sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `<article class="source-row ${source.optional?'source-optional':''}" data-source-id="${esc(source.id||sourceType(source))}"><div class="source-row-main"><div class="source-title-line"><strong>${esc(sourceLabel(source))}</strong><span class="source-type">${esc(sourceType(source))}</span></div><small>${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}</small></div><span class="source-status ${status}">${esc(status==='unavailable'?'Unavailable':status)}</span><dl class="source-facts source-registry-facts"><div><dt>Integration</dt><dd>${esc(source.optional?'Optional adapter':'Registered')}</dd></div><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Available</dt><dd>${available?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${sourceState(source)?'Yes':'No'}</dd></div><div><dt>API credential</dt><dd>${esc(credential)}</dd></div><div><dt>Terms</dt><dd>${esc(terms)}</dd></div><div><dt>Owner</dt><dd>${esc(owner)}</dd></div><div><dt>Rate limit</dt><dd>${esc(rate)}</dd></div><div><dt>Daily quota</dt><dd>${esc(daily)}</dd></div><div><dt>Last health</dt><dd>${esc(lastHealth)} · ${esc(health)}</dd></div><div><dt>Success / error</dt><dd>${esc(success)}<br>${esc(error)}</dd></div><div><dt>Circuit</dt><dd>${esc(circuit)} · ${esc(failures)} failures</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="review" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Review</button><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id||'')}" ${source.optional||!available?'disabled':''}>${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
|
||||||
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
|
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
|
||||||
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:true,configured:false,available:false,enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Optional integrations remain unavailable until configured and approved.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
|
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:Boolean(adapter.optional),configured:false,available:Boolean(adapter.available),enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Configure and enable a ready source to begin discovery.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
|
||||||
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
|
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
|
||||||
async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source || source.optional) return; if (action === 'review') { selectedSourceId=id; document.querySelector(`[data-source-id="${CSS.escape(String(id))}"]`)?.scrollIntoView({behavior:'smooth',block:'center'}); sourceMessage('Source details are shown below. Review terms, owner, limits, health, and circuit state before enabling.'); return; } try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
|
async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source || source.optional) return; if (action === 'review') { selectedSourceId=id; document.querySelector(`[data-source-id="${CSS.escape(String(id))}"]`)?.scrollIntoView({behavior:'smooth',block:'center'}); sourceMessage('Source details are shown below. Review terms, owner, limits, health, and circuit state before enabling.'); return; } try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
|
||||||
async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } }
|
async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
],
|
],
|
||||||
"integrity": {
|
"integrity": {
|
||||||
"config.js": "sha256-12a10f772029a5ee6d813ed9fd61dfc7ff877aa356bf90f65987948cc3274f90",
|
"config.js": "sha256-12a10f772029a5ee6d813ed9fd61dfc7ff877aa356bf90f65987948cc3274f90",
|
||||||
"app.js": "sha256-de1bc9074e8f41d5a964f199e220ce5b48fabbfd0e0301000d3870db84696a43",
|
"app.js": "sha256-eef64a4296ec8dea6802937af88269b9f759de72fb793f706ac05d30549fcec9",
|
||||||
"styles.css": "sha256-ddbb75572a2e80e19a99834a8fb61540e0d94fffe4658429c99116242034183c",
|
"styles.css": "sha256-ddbb75572a2e80e19a99834a8fb61540e0d94fffe4658429c99116242034183c",
|
||||||
"index.html": "sha256-2cba8095f33d9eaed8d73c33eecb08b82b223448240e9ef0d96de8c836122da8",
|
"index.html": "sha256-2cba8095f33d9eaed8d73c33eecb08b82b223448240e9ef0d96de8c836122da8",
|
||||||
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
|
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ check('routes.contracts', 'all critical API route contracts are referenced by th
|
|||||||
check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest'));
|
check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest'));
|
||||||
check('auth.display-name-greeting', 'authenticated display name drives the greeting and identity', js.includes('currentUser.display_name') && js.includes("$('userGreetingName').textContent") && !html.includes('Good morning, Alex'));
|
check('auth.display-name-greeting', 'authenticated display name drives the greeting and identity', js.includes('currentUser.display_name') && js.includes("$('userGreetingName').textContent") && !html.includes('Good morning, Alex'));
|
||||||
check('sources.registry-status', 'source registry exposes governed configuration and operational status fields', all(['source_code', 'display_name', 'configured', 'available', 'enabled', 'API credential', 'Terms', 'Owner', 'Rate limit', 'Daily quota', 'Last health', 'Success / error', 'Circuit', 'data-source-action="review"', 'data-source-action="test"'], token => `${html}\n${js}\n${css}`.includes(token)));
|
check('sources.registry-status', 'source registry exposes governed configuration and operational status fields', all(['source_code', 'display_name', 'configured', 'available', 'enabled', 'API credential', 'Terms', 'Owner', 'Rate limit', 'Daily quota', 'Last health', 'Success / error', 'Circuit', 'data-source-action="review"', 'data-source-action="test"'], token => `${html}\n${js}\n${css}`.includes(token)));
|
||||||
check('sources.optional-gated', 'optional adapters are rendered unavailable until configured', all(['/api/v1/sources/adapters', 'configuration-gated', 'Optional adapter', 'source.optional', 'available:false'], token => `${html}\n${js}\n${css}`.includes(token)));
|
check('sources.optional-gated', 'optional adapters are rendered unavailable until configured', all(['/api/v1/sources/adapters', 'configuration-gated', 'Optional adapter', 'source.optional', 'available:Boolean(adapter.available)'], token => `${html}\n${js}\n${css}`.includes(token)));
|
||||||
check('sources.setup-affordances', 'manual and CSV setup affordances remain explicit and disabled by default', all(['Manual records', 'CSV import', 'CSV content is required', 'enabled:false', 'remains disabled'], token => `${html}\n${js}\n${css}`.includes(token)));
|
check('sources.setup-affordances', 'manual and CSV setup affordances remain explicit and disabled by default', all(['Manual records', 'CSV import', 'CSV content is required', 'enabled:false', 'remains disabled'], token => `${html}\n${js}\n${css}`.includes(token)));
|
||||||
check('discovery.operator-controls', 'discovery builder and run controls are represented', all(['data-run-action="pause"', 'data-run-action="resume"', 'data-run-action="cancel"', 'live-log', 'source-health', 'daily_limit', 'source_ids', 'schedule'], token => `${html}\n${js}\n${css}`.includes(token)));
|
check('discovery.operator-controls', 'discovery builder and run controls are represented', all(['data-run-action="pause"', 'data-run-action="resume"', 'data-run-action="cancel"', 'live-log', 'source-health', 'daily_limit', 'source_ids', 'schedule'], token => `${html}\n${js}\n${css}`.includes(token)));
|
||||||
check('prospects.filter-contract', 'prospect explorer exposes source, geography, category, and contact filters', all(['sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'contact_status'], token => `${html}\n${js}\n${css}`.includes(token)));
|
check('prospects.filter-contract', 'prospect explorer exposes source, geography, category, and contact filters', all(['sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'contact_status'], token => `${html}\n${js}\n${css}`.includes(token)));
|
||||||
|
|||||||
Reference in New Issue
Block a user