mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
386 lines
17 KiB
Go
386 lines
17 KiB
Go
package jobs
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/instancesettings"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/events"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailclient"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
func strp(s string) *string { return &s }
|
|
|
|
// chromeUA is an ordinary desktop browser: the user agent a security gateway
|
|
// presents, which is exactly why the UA rules cannot see one.
|
|
const chromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
|
const bareWebKitUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)"
|
|
|
|
// seen builds the engagement the per-event rules read, with a browser UA and
|
|
// no recognised source unless the test says otherwise.
|
|
func seen(sent time.Time, at time.Time) engagement {
|
|
return engagement{userAgent: strp(chromeUA), sentAt: &sent, at: at}
|
|
}
|
|
|
|
// from marks the engagement as coming from a source the edge recognised.
|
|
func (e engagement) from(label string, probable bool) engagement {
|
|
e.scanner, e.probable = strp(label), probable
|
|
return e
|
|
}
|
|
|
|
// opens and clicks are the window pair each kind of event is judged against.
|
|
func opens(t instancesettings.Tracking) (time.Duration, time.Duration) {
|
|
return t.OpenWindow(), t.ProbableWindow(t.OpenWindow())
|
|
}
|
|
|
|
func clicks(t instancesettings.Tracking) (time.Duration, time.Duration) {
|
|
return t.ClickWindow(), t.ProbableWindow(t.ClickWindow())
|
|
}
|
|
|
|
func TestIsInstantUsesTheDispatchClock(t *testing.T) {
|
|
sent := time.Now()
|
|
window := time.Minute
|
|
if !isInstant(&sent, sent.Add(3*time.Second), window) {
|
|
t.Fatal("three seconds after dispatch is a machine")
|
|
}
|
|
if isInstant(&sent, sent.Add(90*time.Second), window) {
|
|
t.Fatal("past the window is a person")
|
|
}
|
|
if isInstant(nil, sent, window) {
|
|
t.Fatal("an unknown dispatch time must never count as instant")
|
|
}
|
|
// The window is a half-open interval, so the boundary itself is already
|
|
// out. Without this the two windows would overlap by a second.
|
|
if isInstant(&sent, sent.Add(window), window) {
|
|
t.Fatal("the boundary is outside the window")
|
|
}
|
|
// A stamp before the dispatch means the two clocks disagree, not that
|
|
// someone read the mail early. The old form compared a raw difference, so
|
|
// every skewed event fell inside the window and was marked automated;
|
|
// the rule abstains instead and lets the user agent and network decide.
|
|
if isInstant(&sent, sent.Add(-time.Hour), window) {
|
|
t.Fatal("an event stamped before dispatch is not instant")
|
|
}
|
|
}
|
|
|
|
// The window is a deployment property, not a constant: the clock starts when
|
|
// the send is handed to the worker, so it has to cover provider queueing and
|
|
// transit before the recipient's gateway has even seen the message.
|
|
func TestMachineWindowsAreOperatorEditable(t *testing.T) {
|
|
sent := time.Now()
|
|
at := sent.Add(90 * time.Second)
|
|
|
|
w, p := opens(instancesettings.DefaultTracking())
|
|
if m, _ := classifyOpen(seen(sent, at), w, p); m {
|
|
t.Fatal("ninety seconds is past the shipped open window")
|
|
}
|
|
widened := instancesettings.Tracking{MachineWindowOpenSeconds: 120}
|
|
widened.Normalize()
|
|
w, p = opens(widened)
|
|
if m, r := classifyOpen(seen(sent, at), w, p); !m || r != repository.EmailOpenReasonInstant {
|
|
t.Fatalf("a widened window catches it, got %v %q", m, r)
|
|
}
|
|
}
|
|
|
|
// Opens and clicks are tuned separately because the two mistakes cost
|
|
// different things: a misjudged open loses a metric, a misjudged click loses
|
|
// the automation behind an interested lead.
|
|
func TestOpenAndClickWindowsAreIndependent(t *testing.T) {
|
|
sent := time.Now()
|
|
windows := instancesettings.DefaultTracking()
|
|
at := sent.Add(45 * time.Second)
|
|
|
|
ow, op := opens(windows)
|
|
if m, r := classifyOpen(seen(sent, at), ow, op); !m || r != repository.EmailOpenReasonInstant {
|
|
t.Fatalf("forty-five seconds is inside the shipped open window, got %v %q", m, r)
|
|
}
|
|
cw, cp := clicks(windows)
|
|
if m, r := classifyClick(seen(sent, at), cw, cp); m || r != "" {
|
|
t.Fatalf("the same moment is outside the shipped click window, got %v %q", m, r)
|
|
}
|
|
}
|
|
|
|
func TestLateBareWebKitOpenIsNotEnoughToProveApplePrefetch(t *testing.T) {
|
|
sent := time.Now()
|
|
at := sent.Add(24 * time.Minute)
|
|
w, p := opens(instancesettings.DefaultTracking())
|
|
|
|
if machine, reason := classifyOpen(engagement{userAgent: strp(bareWebKitUA), sentAt: &sent, at: at}, w, p); machine || reason != "" {
|
|
t.Fatalf("a late unlabelled image fetch is a person, got %v %q", machine, reason)
|
|
}
|
|
if machine, reason := classifyOpen(engagement{userAgent: strp(bareWebKitUA), sentAt: &sent, at: sent.Add(5 * time.Second)}, w, p); !machine || reason != repository.EmailOpenReasonPrefetch {
|
|
t.Fatalf("the same proxy signature at delivery is a prefetch, got %v %q", machine, reason)
|
|
}
|
|
if machine, reason := classifyOpen(engagement{userAgent: strp(bareWebKitUA), at: at}, w, p); machine || reason != "" {
|
|
t.Fatalf("the proxy signature without a dispatch clock is inconclusive, got %v %q", machine, reason)
|
|
}
|
|
}
|
|
|
|
func TestBareWebKitNamesBothClientsAndNoDevice(t *testing.T) {
|
|
origin := (&TrackingConsumer{}).originOf(&events.TrackingEvent{EventType: events.EventTypeEmailOpened, UserAgent: strp(bareWebKitUA)})
|
|
|
|
if origin.Client != mailclient.BareWebKitClient || origin.ClientType != models.EngagementClientApp {
|
|
t.Fatalf("bare webkit client = %q/%q, want %q app", origin.Client, origin.ClientType, mailclient.BareWebKitClient)
|
|
}
|
|
if origin.DeviceType != "" || origin.OS != "" || origin.Browser != "" || origin.BrowserVersion != "" || origin.DeviceHidden {
|
|
t.Fatalf("bare webkit invented device metadata: %+v", origin)
|
|
}
|
|
|
|
compatibilityOnly := "Mozilla/5.0 (KHTML, like Gecko)"
|
|
if isImageFetcher(&compatibilityOnly) {
|
|
t.Fatal("the generic compatibility suffix without an AppleWebKit engine is not an image fetcher")
|
|
}
|
|
}
|
|
|
|
// Mail Privacy Protection sends the bare product token. At delivery it is a
|
|
// prefetch, later a person on an Apple device, and never a device or a city.
|
|
func TestPrivacyProxyOpen(t *testing.T) {
|
|
sent := time.Now()
|
|
w, p := opens(instancesettings.DefaultTracking())
|
|
mpp := "Mozilla/5.0"
|
|
|
|
if machine, reason := classifyOpen(engagement{userAgent: &mpp, sentAt: &sent, at: sent.Add(5 * time.Second)}, w, p); !machine || reason != repository.EmailOpenReasonPrefetch {
|
|
t.Fatalf("the privacy relay at delivery is a prefetch, got %v %q", machine, reason)
|
|
}
|
|
origin := (&TrackingConsumer{}).originOf(&events.TrackingEvent{EventType: events.EventTypeEmailOpened, UserAgent: &mpp})
|
|
if origin.Client != "Apple Mail" || !origin.DeviceHidden || origin.DeviceType != "" || origin.City != "" {
|
|
t.Fatalf("privacy relay origin = %+v", origin)
|
|
}
|
|
}
|
|
|
|
// Gmail's proxy carries a fixed Windows XP Firefox; none of it is the reader's.
|
|
func TestGmailProxyClaimsNoDevice(t *testing.T) {
|
|
ua := "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)"
|
|
origin := (&TrackingConsumer{}).originOf(&events.TrackingEvent{EventType: events.EventTypeEmailOpened, UserAgent: &ua})
|
|
want := models.EngagementOrigin{Client: "Gmail", DeviceHidden: true}
|
|
if origin != want {
|
|
t.Fatalf("gmail proxy origin = %+v, want %+v", origin, want)
|
|
}
|
|
}
|
|
|
|
func TestClassifyClick(t *testing.T) {
|
|
sent := time.Now()
|
|
w, p := clicks(instancesettings.DefaultTracking())
|
|
|
|
bare := seen(sent, sent.Add(time.Minute))
|
|
bare.userAgent = nil
|
|
if m, r := classifyClick(bare, w, p); !m || r != repository.LinkClickReasonPrefetch {
|
|
t.Fatalf("no user agent = prefetch, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyClick(seen(sent, sent.Add(2*time.Second)), w, p); !m || r != repository.LinkClickReasonInstant {
|
|
t.Fatalf("a browser UA two seconds after dispatch = instant, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyClick(seen(sent, sent.Add(time.Minute)), w, p); m || r != "" {
|
|
t.Fatalf("a browser a minute later is a person, got %v %q", m, r)
|
|
}
|
|
}
|
|
|
|
// A security gateway walks a message with an ordinary browser's user agent
|
|
// and can do it long after delivery, so neither the UA rules nor the machine
|
|
// window sees it. The edge's verdict on the source network is what does, and
|
|
// it outranks both: a Chrome UA from a mail-filtering network is still a scan.
|
|
func TestClassifyScannerSourceOutranksTheUserAgent(t *testing.T) {
|
|
sent := time.Now()
|
|
late := sent.Add(time.Hour)
|
|
windows := instancesettings.DefaultTracking()
|
|
ow, op := opens(windows)
|
|
cw, cp := clicks(windows)
|
|
|
|
if m, r := classifyClick(seen(sent, late).from("microsoft-365-protection", false), cw, cp); !m || r != repository.LinkClickReasonScanner {
|
|
t.Fatalf("a click from a scanner network = scanner, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyOpen(seen(sent, late).from("microsoft-365-protection", false), ow, op); !m || r != repository.EmailOpenReasonScanner {
|
|
t.Fatalf("an open from a scanner network = scanner, got %v %q", m, r)
|
|
}
|
|
// An empty label is the same as none: the edge recognised nothing, and a
|
|
// blank string must not silently condemn every event that carries it.
|
|
if m, r := classifyOpen(seen(sent, late).from(" ", false), ow, op); m || r != "" {
|
|
t.Fatalf("a blank scanner label is not a verdict, got %v %q", m, r)
|
|
}
|
|
// Nor may the probable flag alone condemn one: it qualifies a label, and
|
|
// with no label there is nothing to qualify.
|
|
if m, r := classifyOpen(seen(sent, late).from(" ", true), ow, op); m || r != "" {
|
|
t.Fatalf("a blank label is not a verdict when probable either, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyClick(seen(sent, late), cw, cp); m || r != "" {
|
|
t.Fatalf("no scanner label leaves the click a person's, got %v %q", m, r)
|
|
}
|
|
}
|
|
|
|
// Proofpoint and Mimecast run browser isolation: a click ticket walked from
|
|
// their networks may be the delivery-time scan or a person reading the page
|
|
// their gateway rendered. The label cannot settle that, so it widens the
|
|
// window instead. Inside it the event is the scan; outside it the person.
|
|
func TestProbableScannerWidensTheWindowInsteadOfDeciding(t *testing.T) {
|
|
sent := time.Now()
|
|
windows := instancesettings.DefaultTracking()
|
|
cw, cp := clicks(windows)
|
|
ow, op := opens(windows)
|
|
|
|
// A minute after dispatch is well past the 30s click window, so without
|
|
// the label this is a person. With it, it is the arrival scan.
|
|
scan := sent.Add(time.Minute)
|
|
if m, r := classifyClick(seen(sent, scan), cw, cp); m {
|
|
t.Fatalf("a minute later with no label is a person, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyClick(seen(sent, scan).from("proofpoint", true), cw, cp); !m || r != repository.LinkClickReasonScanner {
|
|
t.Fatalf("inside the probable window it is the scan, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyOpen(seen(sent, scan).from("proofpoint", true), ow, op); !m || r != repository.EmailOpenReasonScanner {
|
|
t.Fatalf("the same for an open, got %v %q", m, r)
|
|
}
|
|
|
|
// An hour later the person has read their mail and clicked through
|
|
// isolation. A certain label would take that click and the automation
|
|
// behind it; a probable one must not.
|
|
late := sent.Add(time.Hour)
|
|
if m, r := classifyClick(seen(sent, late).from("proofpoint", true), cw, cp); m || r != "" {
|
|
t.Fatalf("past the probable window an isolated click is a person's, got %v %q", m, r)
|
|
}
|
|
if m, r := classifyOpen(seen(sent, late).from("proofpoint", true), ow, op); m || r != "" {
|
|
t.Fatalf("past the probable window an isolated open is a person's, got %v %q", m, r)
|
|
}
|
|
// The same source marked certain is the behaviour we deliberately did not
|
|
// ship, and the contrast is the whole point of the flag.
|
|
if m, _ := classifyClick(seen(sent, late).from("proofpoint", false), cw, cp); !m {
|
|
t.Fatal("a certain label still decides on its own")
|
|
}
|
|
}
|
|
|
|
// Naming a network may only ever catch more scans. A probable window set
|
|
// shorter than the window the event would get anyway must not hand a scan
|
|
// back: the classifier takes the wider of the two.
|
|
func TestAProbableLabelNeverWeakensTheOrdinaryWindow(t *testing.T) {
|
|
sent := time.Now()
|
|
narrow := instancesettings.Tracking{
|
|
MachineWindowOpenSeconds: 60,
|
|
MachineWindowClickSeconds: 30,
|
|
MachineWindowProbableSeconds: 1,
|
|
}
|
|
narrow.Normalize()
|
|
cw, cp := clicks(narrow)
|
|
if cp < cw {
|
|
t.Fatalf("the probable window may not fall below the click window: %v < %v", cp, cw)
|
|
}
|
|
at := sent.Add(5 * time.Second)
|
|
if m, _ := classifyClick(seen(sent, at).from("proofpoint", true), cw, cp); !m {
|
|
t.Fatal("a probable source inside the ordinary window is still a machine")
|
|
}
|
|
if m, _ := classifyClick(seen(sent, at), cw, cp); !m {
|
|
t.Fatal("and so is an unlabelled one")
|
|
}
|
|
}
|
|
|
|
// The window is its own setting with its own bounds, because how long a
|
|
// security vendor takes to detonate a link is the vendor's property, not the
|
|
// instance's. Zero means the compiled default, as everywhere else here.
|
|
func TestProbableWindowIsOperatorEditable(t *testing.T) {
|
|
var zero instancesettings.Tracking
|
|
zero.Normalize()
|
|
if got := zero.MachineWindowProbableSeconds; got != config.TrackingMachineWindowProbableSecondsDefault {
|
|
t.Fatalf("an unwritten section takes the compiled default, got %d", got)
|
|
}
|
|
over := instancesettings.Tracking{MachineWindowProbableSeconds: config.TrackingMachineWindowProbableSecondsMax + 1}
|
|
over.Normalize()
|
|
if got := over.MachineWindowProbableSeconds; got != config.TrackingMachineWindowProbableSecondsMax {
|
|
t.Fatalf("above the ceiling clamps to it, got %d", got)
|
|
}
|
|
// It reaches past the 900s ceiling the other two windows have, which is
|
|
// the point: a vendor may detonate a link hours after delivery.
|
|
if config.TrackingMachineWindowProbableSecondsMax <= config.TrackingMachineWindowSecondsMax {
|
|
t.Fatal("the probable window must be allowed to exceed the ordinary ones")
|
|
}
|
|
wide := instancesettings.Tracking{MachineWindowProbableSeconds: 7200}
|
|
wide.Normalize()
|
|
sent := time.Now()
|
|
cw, cp := clicks(wide)
|
|
if m, _ := classifyClick(seen(sent, sent.Add(time.Hour)).from("mimecast", true), cw, cp); !m {
|
|
t.Fatal("a two-hour probable window catches an hour-old scan")
|
|
}
|
|
}
|
|
|
|
func TestEventTimeFallsBackToNow(t *testing.T) {
|
|
stamp := "2026-09-03T10:00:00Z"
|
|
if got := eventTime(stamp); !got.Equal(time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("unexpected parse: %v", got)
|
|
}
|
|
if d := time.Since(eventTime("garbage")); d < 0 || d > time.Minute {
|
|
t.Fatalf("unreadable stamp should fall back to now, got %v ago", d)
|
|
}
|
|
}
|
|
|
|
// The one-way property the catalogue's `probable` entries rest on, and the one
|
|
// this documentation got wrong once: adding a probable label to a source may
|
|
// move an event from counted-as-human to counted-as-automated, and never the
|
|
// reverse. If it could go the other way, enabling a vendor ASN would let a
|
|
// delivery-time scan count as engagement, which is the failure the whole
|
|
// feature exists to prevent.
|
|
//
|
|
// Asserted over the grid rather than argued, because the argument is what was
|
|
// wrong: for every window pair, every arrival time and every user agent, an
|
|
// event the rules already call automated must stay automated once labelled.
|
|
func TestAProbableLabelNeverTurnsAnAutomatedEventHuman(t *testing.T) {
|
|
sent := time.Now()
|
|
agents := map[string]*string{
|
|
"browser": strp(chromeUA),
|
|
"apple mpp": strp("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)"),
|
|
"empty": strp(""),
|
|
"absent": nil,
|
|
}
|
|
windows := []instancesettings.Tracking{
|
|
instancesettings.DefaultTracking(),
|
|
{MachineWindowOpenSeconds: 5, MachineWindowClickSeconds: 5, MachineWindowProbableSeconds: 1},
|
|
{MachineWindowOpenSeconds: 900, MachineWindowClickSeconds: 900, MachineWindowProbableSeconds: 86400},
|
|
}
|
|
elapsed := []time.Duration{
|
|
-time.Hour, 0, time.Second, 29 * time.Second, time.Minute,
|
|
9 * time.Minute, 11 * time.Minute, time.Hour, 48 * time.Hour,
|
|
}
|
|
|
|
for _, w := range windows {
|
|
w.Normalize()
|
|
for name, ua := range agents {
|
|
for _, d := range elapsed {
|
|
base := engagement{userAgent: ua, sentAt: &sent, at: sent.Add(d)}
|
|
labelled := base
|
|
labelled.scanner, labelled.probable = strp("proofpoint"), true
|
|
|
|
ow, op := opens(w)
|
|
if bare, _ := classifyOpen(base, ow, op); bare {
|
|
if got, _ := classifyOpen(labelled, ow, op); !got {
|
|
t.Fatalf("open %s at %v under %+v: labelling turned automated into human", name, d, w)
|
|
}
|
|
}
|
|
cw, cp := clicks(w)
|
|
if bare, _ := classifyClick(base, cw, cp); bare {
|
|
if got, _ := classifyClick(labelled, cw, cp); !got {
|
|
t.Fatalf("click %s at %v under %+v: labelling turned automated into human", name, d, w)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A proxy's network is the provider's, so its place is dropped; Apple's relay
|
|
// keeps the region; a direct fetch keeps everything.
|
|
func TestDeriveOriginKeepsOnlyTheMeaningfulPlace(t *testing.T) {
|
|
place := models.EngagementOrigin{CountryCode: "US", Region: "California", City: "Mountain View"}
|
|
|
|
if o := deriveOrigin("Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)", false, place); o.CountryCode != "" || o.City != "" || !o.DeviceHidden {
|
|
t.Fatalf("gmail proxy kept a place: %+v", o)
|
|
}
|
|
if o := deriveOrigin("Mozilla/5.0", false, place); o.CountryCode != "US" || o.Region != "California" || o.City != "" {
|
|
t.Fatalf("apple relay should keep the region only: %+v", o)
|
|
}
|
|
if o := deriveOrigin(chromeUA, false, place); o.City != "Mountain View" || o.ClientType != models.EngagementClientWebmail {
|
|
t.Fatalf("a direct fetch keeps its place: %+v", o)
|
|
}
|
|
if o := deriveOrigin("Mozilla/5.0", true, place); o.Client != "" || o.DeviceHidden || o.City != "Mountain View" {
|
|
t.Fatalf("a click is never read as a proxy: %+v", o)
|
|
}
|
|
}
|