stage-30: home page (Newsline digest) + PWA + mobile pass

NEW / start page replaces the redirect-to-/cases:
- KPI strip (cases, IOCs, +24h, high/critical,  enforced 24h, ledger total) —
  clickable, responsive grid (2 cols mobile, 3 mid, 6 desktop).
- Recent activity feed: ledger events (enforced/submitted/rejected/failed) +
  newest case ingests, interleaved newest-first, with severity badges, icons,
  case links. Sources via lines/news.py.
- Feed health sidebar: per-feed count + last ingest time.

PWA:
- /static/manifest.json declares a standalone install with theme colors.
- /static/sw.js — cache-first for static, network-first for HTML/API, with a
  graceful offline page. Registered from / scope via a dedicated /sw.js route
  that sets Service-Worker-Allowed: /.
- viewport + apple-touch-icon + theme-color meta tags in base.html.

Mobile pass on the chrome:
- Topbar wraps; nav horizontally scrolls instead of crowding; brand-sub hides.
- Tables (cases, ledger) scroll horizontally on narrow screens instead of
  exploding the layout.
- Hero / KPI / news-list layouts collapse cleanly at < 720px.

4 news tests; verified locally — home page renders, /sw.js serves with
Service-Worker-Allowed: /, manifest is valid JSON.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-25 17:18:40 +02:00
parent 7a57a7390a
commit 16cf873044
8 changed files with 501 additions and 4 deletions

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from typing import List
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
@@ -16,6 +16,7 @@ from psyc.cockpit import adminauth, docker_view, inference, journey as journey_v
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
from psyc.lines import news as news_line
from psyc.lines import respond as respond_line
from psyc.lines import route as route_line
from psyc.lines import seal as seal_line
@@ -41,9 +42,15 @@ def _admin_ok(request: Request) -> bool:
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
cases = db.list_cases(limit=200)
total = db.case_count()
return TEMPLATES.TemplateResponse(request, "cases.html", {"cases": cases, "total": total})
return TEMPLATES.TemplateResponse(
request,
"home.html",
{
"kpis": news_line.kpis(),
"items": news_line.recent_items(limit=24),
"feeds": news_line.feed_health(),
},
)
@app.get("/cases", response_class=HTMLResponse)
@@ -112,6 +119,17 @@ def healthz() -> dict:
return {"status": "ok"}
# PWA service worker — must live at the root so its scope is the whole site.
# Static file is on disk under /static/sw.js; this route just serves it from /.
@app.get("/sw.js", include_in_schema=False)
def service_worker() -> FileResponse:
return FileResponse(
HERE / "static" / "sw.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@app.get("/api/inference-status")
def inference_status() -> dict:
adapter = inference.server_adapter()

View File

@@ -719,3 +719,107 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
.port-pill { display: inline-block; padding: 1px 7px; margin-right: 4px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.4); border-radius: 10px; color: var(--amber); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
.td-portlist { margin: 0; padding-left: 18px; font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--text); }
.td-portlist li { margin: 2px 0; }
/* ── home / Newsline ────────────────────────────────────────── */
.hero {
display: flex; gap: 16px; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; margin-bottom: 18px;
padding: 28px 28px 22px;
background: radial-gradient(700px 300px at 12% 0%, rgba(30,200,255,0.10), transparent 60%),
radial-gradient(700px 300px at 95% 100%, rgba(167,139,250,0.08), transparent 60%),
var(--panel);
border: 1px solid var(--border); border-radius: 12px; position: relative;
}
.hero::before {
content: ""; position: absolute; top: 0; left: 20%; right: 20%; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 14px var(--accent); opacity: 0.85;
}
.hero-title {
margin: 0; font-size: clamp(20px, 4vw, 30px); letter-spacing: 0.04em;
text-shadow: 0 0 22px var(--accent-glow);
}
.hero-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 16px; border: 1px solid var(--accent); border-radius: 8px;
color: var(--accent); font-family: var(--font-display); letter-spacing: 0.08em; font-size: 12px;
background: rgba(30,200,255,0.06); white-space: nowrap;
}
.hero-cta:hover { background: rgba(30,200,255,0.15); text-decoration: none; box-shadow: 0 0 14px var(--accent-glow); }
.kpis {
display: grid; gap: 10px;
grid-template-columns: repeat(2, 1fr); /* mobile: 2 columns */
margin-bottom: 18px;
}
@media (min-width: 720px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1080px) { .kpis { grid-template-columns: repeat(6, 1fr); } }
.kpi {
display: block; padding: 14px 12px; border: 1px solid var(--border); border-radius: 10px;
background: linear-gradient(180deg, var(--panel-2), rgba(18,22,30,0.55));
color: var(--text); text-decoration: none; transition: all 0.15s;
}
.kpi:hover { border-color: var(--accent); box-shadow: 0 0 14px var(--accent-glow); text-decoration: none; }
.kpi-num { font-family: var(--font-display); font-size: clamp(22px, 4vw, 28px); font-weight: 700; line-height: 1; }
.kpi-label { font-size: 11px; color: var(--muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.08em; }
.kpi-warn { border-color: rgba(251,191,36,0.4); }
.kpi-warn .kpi-num { color: var(--amber); }
.kpi-accent { border-color: rgba(30,200,255,0.4); }
.kpi-accent .kpi-num { color: var(--accent); }
.home-grid {
display: grid; gap: 16px;
grid-template-columns: 1fr; /* mobile: stacked */
}
@media (min-width: 980px) {
.home-grid { grid-template-columns: minmax(0,1fr) 280px; } /* desktop: main + sidebar */
}
.news-list { list-style: none; margin: 0; padding: 0; }
.news-item {
display: grid; grid-template-columns: 32px 1fr; gap: 12px;
padding: 12px 0; border-bottom: 1px solid var(--border);
}
.news-item:last-child { border-bottom: none; }
.news-icon {
width: 32px; height: 32px; display: grid; place-items: center;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
font-size: 14px; color: var(--muted); flex-shrink: 0;
}
.news-kind-enforced .news-icon { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.08); }
.news-kind-rejected .news-icon { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.06); }
.news-kind-submitted .news-icon { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.06); }
.news-kind-failed .news-icon { color: var(--red); border-color: var(--red); }
.news-item.sev-critical .news-icon { color: var(--red); border-color: var(--red); }
.news-item.sev-high .news-icon { color: var(--amber); border-color: var(--amber); }
.news-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.news-headline { font-family: var(--font-display); font-size: 14px; color: var(--text); }
.news-sub { color: var(--muted); font-size: 12px; margin-top: 3px; line-height: 1.4; word-break: break-word; }
.news-meta { color: var(--muted); font-size: 11px; margin-top: 4px; font-family: ui-monospace, Menlo, monospace; }
.news-meta a { color: var(--accent); }
.feed-health { list-style: none; margin: 0; padding: 0; }
.feed-row {
display: grid; grid-template-columns: 1fr auto auto; gap: 10px;
padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12px;
}
.feed-row:last-child { border-bottom: none; }
.feed-name { color: var(--text); }
.feed-count { color: var(--accent); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
.feed-time { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
/* ── mobile pass for the rest of the cockpit ────────────────── */
@media (max-width: 720px) {
.topbar { flex-wrap: wrap; gap: 8px; padding: 8px 14px; }
.brand-icon { height: 36px; width: 120px; }
.brand-sub { display: none; }
.nav { width: 100%; overflow-x: auto; white-space: nowrap; padding-bottom: 4px; }
.nav a { margin-left: 0; margin-right: 14px; font-size: 13px; }
.family-icon { height: 32px; }
.model-status, .admin-chip { font-size: 9.5px; padding-top: 2px; padding-bottom: 2px; }
.content { padding: 14px; }
.panel { padding: 14px; }
/* tables: let horizontal scroll handle wide columns */
table.cases, table.ledger { display: block; overflow-x: auto; white-space: nowrap; }
}

View File

@@ -0,0 +1,17 @@
{
"name": "psyc — defensive CTI cockpit",
"short_name": "psyc",
"description": "Defensive cyber-threat-intelligence routing, sealing, and human-gated response.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f1115",
"theme_color": "#1ec8ff",
"orientation": "any",
"icons": [
{ "src": "/static/psyc-logo.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/static/psyc-logo.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/static/psyc-logo.png", "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable" }
],
"categories": ["security", "productivity"]
}

View File

@@ -0,0 +1,61 @@
// psyc — minimal service worker.
// Strategy:
// • static assets (CSS/JS/PNG) → cache-first, fall back to network
// • HTML pages and API responses → network-first (always fresh data)
// This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back.
const CACHE_VERSION = "psyc-v1";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",
"/static/psyc-logo.png",
"/static/nn-sc-icon.png",
"/static/manifest.json",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_VERSION).then((c) => c.addAll(STATIC_ASSETS)).catch(() => {})
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
function isStatic(req) {
return /\.(css|js|png|svg|ico|woff2?)$/.test(new URL(req.url).pathname) ||
new URL(req.url).pathname.startsWith("/static/");
}
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
if (isStatic(req)) {
// cache-first
event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((resp) => {
const copy = resp.clone();
caches.open(CACHE_VERSION).then((c) => c.put(req, copy)).catch(() => {});
return resp;
}))
);
} else {
// network-first for HTML + API
event.respondWith(
fetch(req).catch(() => caches.match(req).then((hit) => hit || new Response(
"<!doctype html><meta charset=utf-8><title>psyc offline</title>" +
"<style>body{background:#0f1115;color:#d8dee9;font-family:sans-serif;padding:40px;text-align:center}h1{color:#1ec8ff}</style>" +
"<h1>psyc · offline</h1><p>The cockpit is offline. Reconnect to load fresh data.</p>",
{ headers: { "Content-Type": "text/html" } }
)))
);
}
});

View File

@@ -2,13 +2,27 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="psyc">
<title>{% block title %}psyc cockpit{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
<link rel="apple-touch-icon" href="/static/psyc-logo.png">
<link rel="manifest" href="/static/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/cockpit.css">
<link rel="stylesheet" href="/static/psyc-tokens.css">
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {});
});
}
</script>
</head>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}psyc — operations cockpit{% endblock %}
{% block content %}
<section class="hero">
<div class="hero-text">
<h1 class="hero-title">Defensive CTI in motion</h1>
<p class="hero-sub">What psyc has seen and done — at a glance.</p>
</div>
<a class="hero-cta" href="/cases">All cases →</a>
</section>
<section class="kpis">
<a class="kpi" href="/cases">
<div class="kpi-num">{{ kpis.cases }}</div>
<div class="kpi-label">cases tracked</div>
</a>
<a class="kpi" href="/lookup">
<div class="kpi-num">{{ kpis.iocs }}</div>
<div class="kpi-label">IOCs indexed</div>
</a>
<div class="kpi">
<div class="kpi-num">+{{ kpis.new_24h }}</div>
<div class="kpi-label">new in 24 h</div>
</div>
<div class="kpi kpi-warn">
<div class="kpi-num">{{ kpis.high_total }}</div>
<div class="kpi-label">high / critical</div>
</div>
<a class="kpi kpi-accent" href="/response">
<div class="kpi-num">⚡ {{ kpis.enforcements_24h }}</div>
<div class="kpi-label">enforced 24 h</div>
</a>
<a class="kpi" href="/ledger">
<div class="kpi-num">{{ kpis.ledger_total }}</div>
<div class="kpi-label">ledger entries</div>
</a>
</section>
<div class="home-grid">
<section class="panel home-news">
<div class="panel-head">
<h2>Recent activity</h2>
<span class="count">{{ items|length }} items</span>
</div>
<p class="page-intro">Live feed of what psyc has detected and what it has done about it.</p>
{% if not items %}
<p class="empty">Nothing recent yet — start with <code>psyc fetch-all</code>.</p>
{% endif %}
<ol class="news-list">
{% for i in items %}
<li class="news-item news-kind-{{ i.kind }}{% if i.severity %} sev-{{ i.severity }}{% endif %}">
<div class="news-icon">{{ i.icon }}</div>
<div class="news-body">
<div class="news-head">
<span class="news-headline">{{ i.headline }}</span>
{% if i.severity %}<span class="sev-badge">{{ i.severity }}</span>{% endif %}
</div>
<div class="news-sub">{{ i.body }}</div>
<div class="news-meta">
<time>{{ i.timestamp.strftime('%Y-%m-%d %H:%M') }}</time>
{% if i.case_id %} · <a href="/cases/{{ i.case_id }}">{{ i.case_id }}</a>{% endif %}
</div>
</div>
</li>
{% endfor %}
</ol>
</section>
<aside class="panel home-side">
<div class="panel-head"><h2>Feed health</h2></div>
<p class="page-intro">Where psyc's data is coming from.</p>
<ul class="feed-health">
{% for f in feeds %}
<li class="feed-row">
<span class="feed-name">{{ f.feed }}</span>
<span class="feed-count">{{ f.count }}</span>
<span class="feed-time">{{ f.latest.strftime('%m-%d %H:%M') if f.latest else '—' }}</span>
</li>
{% endfor %}
</ul>
</aside>
</div>
{% endblock %}

126
src/psyc/lines/news.py Normal file
View File

@@ -0,0 +1,126 @@
"""Newsline — turn ledger + case activity into a human-readable digest.
Surfaces what psyc has *done* and what it has *seen* as a stream of news items
for the start page. Pure read aggregation over the existing case and ledger
stores — no new state.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from pydantic import BaseModel
from psyc import db, log
from psyc.lines import ledger as ledger_line
from psyc.models import Case, LedgerEntry, Outcome, Severity
_log = log.get(__name__)
class NewsItem(BaseModel):
timestamp: datetime
kind: str # case | enforced | submitted | rejected | actioned | failed
headline: str
body: str
severity: Optional[str] = None
case_id: Optional[str] = None
icon: str = "" # tiny glyph for the card
class FeedHealth(BaseModel):
feed: str
count: int
latest: Optional[datetime] = None
# ---------- KPI strip ----------------------------------------------------
def kpis() -> Dict[str, int]:
"""Counters shown at the top of the home page."""
cases = db.list_cases(limit=10_000)
today = datetime.now(timezone.utc) - timedelta(hours=24)
new_24h = sum(1 for c in cases if c.ingested_at and c.ingested_at >= today)
high = sum(1 for c in cases
if c.classification.severity in (Severity.HIGH, Severity.CRITICAL))
ledger = ledger_line.list_recent(limit=10_000)
enforcements_24h = sum(1 for e in ledger if e.timestamp >= today and e.outcome is Outcome.ACTIONED)
return {
"cases": len(cases),
"iocs": db.ioc_count(),
"new_24h": new_24h,
"high_total": high,
"enforcements_24h": enforcements_24h,
"ledger_total": ledger_line.count(),
}
# ---------- news items ---------------------------------------------------
_OUTCOME_RENDER = {
Outcome.ACTIONED: ("", "enforced", "psyc enforced the response for {case}{dest}"),
Outcome.SUBMITTED: ("", "submitted", "Submitted to {dest} for {case}"),
Outcome.ACKNOWLEDGED:("", "submitted", "{dest} acknowledged the submission for {case}"),
Outcome.REJECTED: ("", "rejected", "Blocked / declined: {dest} for {case}"),
Outcome.FAILED: ("", "failed", "Delivery to {dest} failed for {case}"),
Outcome.PENDING_APPROVAL: ("", "pending", "Awaiting approval: {dest} for {case}"),
}
def _ledger_to_news(e: LedgerEntry) -> NewsItem:
icon, kind, fmt = _OUTCOME_RENDER.get(e.outcome, ("", "ledger", "Ledger event for {case}"))
headline = fmt.format(case=e.case_id, dest=e.destination)
body = e.detail or f"{e.outcome.value} · TLP:{e.tlp.value}"
return NewsItem(
timestamp=e.timestamp, kind=kind, headline=headline, body=body,
case_id=e.case_id, icon=icon,
)
def _case_to_news(c: Case) -> NewsItem:
sev = c.classification.severity.value if c.classification.severity else None
incident = c.classification.incident_type.value if c.classification.incident_type else "case"
feed = c.source_metadata.get("feed", "feed")
headline = f"New {sev or 'unrated'} {incident} from {feed}"
return NewsItem(
timestamp=c.ingested_at, kind="case",
headline=headline, body=c.summary[:200],
severity=sev, case_id=c.case_id,
icon={"critical": "🚨", "high": "", "medium": "", "low": "·"}.get(sev or "", ""),
)
def recent_items(limit: int = 30, high_only: bool = False) -> List[NewsItem]:
"""Interleave the latest ledger events with the latest case ingests, newest first."""
items: List[NewsItem] = []
for e in ledger_line.list_recent(limit=limit * 2):
items.append(_ledger_to_news(e))
cases = db.list_cases(limit=limit * 2)
for c in cases:
if high_only and c.classification.severity not in (Severity.HIGH, Severity.CRITICAL):
continue
items.append(_case_to_news(c))
items.sort(key=lambda i: i.timestamp, reverse=True)
return items[:limit]
# ---------- feed health (sidebar / footer of home) -----------------------
def feed_health() -> List[FeedHealth]:
"""Per-feed counts + most-recent ingest. Useful as a 'sources live?' panel."""
cases = db.list_cases(limit=10_000)
buckets: Dict[str, FeedHealth] = {}
for c in cases:
feed = c.source_metadata.get("feed") or "unknown"
h = buckets.get(feed)
if h is None:
buckets[feed] = FeedHealth(feed=feed, count=1, latest=c.ingested_at)
else:
h.count += 1
if c.ingested_at and (h.latest is None or c.ingested_at > h.latest):
h.latest = c.ingested_at
out = list(buckets.values())
out.sort(key=lambda h: h.count, reverse=True)
return out

73
tests/test_news.py Normal file
View File

@@ -0,0 +1,73 @@
"""Newsline — start-page digest aggregation."""
from __future__ import annotations
import pytest
from sqlalchemy import create_engine
from psyc import db
from psyc.lines import ledger as ledger_line
from psyc.lines import news
from psyc.models import Outcome, Severity, TLP
from conftest import make_case
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
test_db = tmp_path / "test.db"
eng = create_engine(f"sqlite:///{test_db}", future=True)
db._metadata.create_all(eng, checkfirst=True)
monkeypatch.setattr(db, "_engine", eng)
monkeypatch.setattr(db, "DB_PATH", test_db)
yield test_db
def test_kpis_count_basic(fresh_db):
# vary age_days so make_case() doesn't collide its case_id
db.upsert_case(make_case(feed="urlhaus", urls=["http://1.1.1.1/x"], severity=Severity.LOW, age_days=1))
db.upsert_case(make_case(feed="feodo", ips=["2.2.2.2"], severity=Severity.HIGH, age_days=0))
db.upsert_case(make_case(feed="urlhaus", urls=["http://3.3.3.3/x"], severity=Severity.CRITICAL, age_days=2))
k = news.kpis()
assert k["cases"] == 3
assert k["high_total"] == 2 # high + critical
def test_recent_items_interleaves_ledger_and_cases(fresh_db):
c = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
db.upsert_case(c)
ledger_line.write(
case_id=c.case_id, destination="CERT-Bund", payload_hash="",
submitter_identity="x", tlp=TLP.AMBER, outcome=Outcome.ACTIONED,
)
items = news.recent_items(limit=10)
kinds = {i.kind for i in items}
assert "case" in kinds
assert "enforced" in kinds
# newest-first ordering
assert items == sorted(items, key=lambda i: i.timestamp, reverse=True)
def test_feed_health_groups_by_feed(fresh_db):
db.upsert_case(make_case(feed="urlhaus", urls=["http://a/"], age_days=1))
db.upsert_case(make_case(feed="urlhaus", urls=["http://b/"], age_days=2))
db.upsert_case(make_case(feed="otx", ips=["1.1.1.1"], age_days=1))
h = news.feed_health()
by_feed = {f.feed: f for f in h}
assert by_feed["urlhaus"].count == 2
assert by_feed["otx"].count == 1
# highest count first
assert h[0].feed == "urlhaus"
def test_outcome_kinds_match_render_map(fresh_db):
# Every Outcome should produce a NewsItem (no KeyError).
c = make_case(feed="urlhaus", ips=["1.2.3.4"])
db.upsert_case(c)
for outcome in Outcome:
ledger_line.write(
case_id=c.case_id, destination="X", payload_hash="",
submitter_identity="x", tlp=TLP.AMBER, outcome=outcome,
)
items = news.recent_items(limit=200)
# at least one item per outcome we wrote
assert len([i for i in items if i.kind != "case"]) >= len(list(Outcome))