PrintToVeraeTimeStamp/internal/netprint/airdrop_test.go
Cursor Agent f96ade41bd
Ship the Mac menu bar as an app bundle and use one real stamp path.
The tray and AirDrop helper now live in Verae NetPrint.app so notifications can start, /api/stamp uses the same client as Print-Job and refuses mock receipts, and the default printer name includes the hostname with an mDNS rename when that name is taken.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 02:09:48 +00:00

363 lines
11 KiB
Go

package netprint
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
)
func TestAirDropDefaultsOffAndMenu(t *testing.T) {
if Defaults().AirDropOn() {
t.Fatal("AirDrop is opt-in")
}
if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) {
t.Fatal("sample config should show the switch off")
}
st := Status{Running: true, SupportsAirDrop: true, AirDropEnabled: true, Advertise: true}
var sawDrop, sawAdv bool
for _, e := range BuildMenu(st) {
if e.Kind == "airdrop" {
sawDrop = e.Checked && e.Enabled && e.Label == "AirDrop stamped PDF after each job"
}
if e.Kind == "advertise" {
sawAdv = e.Checked && e.Label == "Advertise on the network"
}
}
if !sawDrop || !sawAdv {
t.Fatalf("drop %v advertise %v", sawDrop, sawAdv)
}
plain := BuildMenu(Status{Running: true})
for _, e := range plain {
if e.Kind == "airdrop" || e.Kind == "advertise" {
t.Fatal("AirDrop controls are macOS only")
}
}
line := discoveryLine(Status{Running: true, MDNSAdvertising: true, MDNSMode: "dns-sd", MDNSDetail: "DNSServiceRegister Verae Timestamp._ipp._tcp.local regtype _ipp._tcp,_universal,_print port 631; _verae._tcp"})
if line != "Discovery — advertised (Bonjour _universal and _print)" {
t.Fatal(line)
}
}
func TestAirDropAgainUsesSavedFiles(t *testing.T) {
clearAirDrop()
t.Cleanup(clearAirDrop)
prev := shareAirDropOffer
var got AirDropOffer
shareAirDropOffer = func(o AirDropOffer) error {
got = o
return nil
}
t.Cleanup(func() { shareAirDropOffer = prev })
rememberAirDrop(AirDropOffer{ReceiptID: "QmReceipt", PDF: "certificate.pdf", JSON: "receipt.json"})
items := withAirDropAgain(BuildMenu(Status{Running: true}))
var item MenuEntry
for _, e := range items {
if e.Kind == "airdrop-again" {
item = e
}
}
if item.Label != airDropAgainTitle+" · QmReceipt" {
t.Fatal(item.Label)
}
if err := Dispatch(Status{Running: true}, item); err != nil {
t.Fatal(err)
}
if got.PDF != "certificate.pdf" || got.JSON != "receipt.json" || got.ReceiptID != "QmReceipt" {
t.Fatalf("%+v", got)
}
}
func TestStampJobTellsMenuBarNotTheDaemon(t *testing.T) {
cfg, path := testConfig(t)
on := true
cfg.AirDropEnabled = &on
s, err := Start(cfg, path, func(pdf, title, user string) (job.Result, error) {
dir := filepath.Dir(pdf)
cert := filepath.Join(dir, "certificate.pdf")
js := filepath.Join(dir, "scan.verae.json")
if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil {
return job.Result{}, err
}
if err := os.WriteFile(js, []byte("{\"receipt_id\":\"QmAir\"}\n"), 0o600); err != nil {
return job.Result{}, err
}
return job.Result{SHA256: strings.Repeat("ab", 32), ReceiptID: "QmAir", CertificatePDF: cert, ReceiptJSON: js, OriginalPDF: pdf}, nil
}, &fakeCtrl{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = s.Shutdown(ctx)
})
prev := shareAirDropOffer
shareAirDropOffer = func(AirDropOffer) error {
t.Error("daemon called AirDrop")
return nil
}
defer func() { shareAirDropOffer = prev }()
var mu sync.Mutex
var got AirDropOffer
var notes int
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dir := t.TempDir()
go watchAirDrop(ctx, s.ControlURL(), dir, func(o AirDropOffer) error {
mu.Lock()
got = o
mu.Unlock()
return nil
}, func(AirDropOffer) { mu.Lock(); notes++; mu.Unlock() })
id, err := s.store.AllocateID()
if err != nil {
t.Fatal(err)
}
ProcessJob(s.store, s.snapshot(), nil, id, "application/pdf", "Note", "stamp", []byte("%PDF-1.4 note"), s.stamp, nil, s.afterJob)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
pdf := got.PDF
mu.Unlock()
if pdf != "" {
break
}
time.Sleep(20 * time.Millisecond)
}
mu.Lock()
offer := got
n := notes
mu.Unlock()
if offer.ReceiptID != "QmAir" || n != 1 {
t.Fatalf("offer %+v notes %d", offer, n)
}
b, err := os.ReadFile(offer.PDF)
if err != nil || string(b) != "%PDF-cert" {
t.Fatalf("pdf %q %v", b, err)
}
jb, err := os.ReadFile(offer.JSON)
if err != nil || !strings.Contains(string(jb), "QmAir") {
t.Fatalf("json %q %v", jb, err)
}
if err := Dispatch(Status{Running: true, AirDropEnabled: true, ControlURL: s.ControlURL()}, MenuEntry{Kind: "airdrop", ID: idAirDrop}); err != nil {
t.Fatal(err)
}
st, err := NewClient(cfg.ControlPort).Status()
if err != nil || st.AirDropEnabled {
t.Fatalf("toggle %+v %v", st.AirDropEnabled, err)
}
acked := false
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
body := readEvents(t, stringsTrimRight(s.ControlURL())+"/api/v1/events?after=0")
if strings.Contains(body, ": ok") && !strings.Contains(body, "QmAir") {
acked = true
break
}
time.Sleep(30 * time.Millisecond)
}
if !acked {
t.Fatal("acked job was still replayed")
}
}
func readEvents(t *testing.T, url string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return string(b)
}
func stringsTrimRight(s string) string {
return strings.TrimRight(s, "/")
}
func TestDarwinDNSRegistersAirPrintSubtypes(t *testing.T) {
if dnsPort(631) != 0x7702 {
t.Fatalf("port %x", dnsPort(631))
}
svc := sampleService()
svc.StampURL = "http://192.168.1.184:631/api/stamp"
insts := serviceToDarwinDNS(svc)
if len(insts) != 2 {
t.Fatal(len(insts))
}
ipp, verae := insts[0], insts[1]
if ipp.RegType != "_ipp._tcp,_universal,_print" || ipp.Name != "Verae Timestamp" || ipp.InstanceName != "Verae Timestamp._ipp._tcp.local" {
t.Fatalf("ipp %+v", ipp)
}
if strings.Contains(ipp.InstanceName, "_sub") || strings.Contains(ipp.RegType, "_sub") || ipp.Host != "" {
t.Fatalf("subtype target or host %+v", ipp)
}
if verae.RegType != "_verae._tcp" || verae.InstanceName != "Verae Timestamp._verae._tcp.local" {
t.Fatalf("verae %+v", verae)
}
if !strings.Contains(strings.Join(verae.TXT, "\n"), "path=/api/stamp") || !strings.Contains(strings.Join(verae.TXT, "\n"), "url=http://192.168.1.184:631/api/stamp") {
t.Fatal(verae.TXT)
}
pkt := &fakePkt{}
prev := listenMDNSFn
called := 0
listenMDNSFn = func() (mdnsConn, error) {
called++
return pkt, nil
}
t.Cleanup(func() { listenMDNSFn = prev })
api := &fakeDNS{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
var mu sync.Mutex
var report mdnsReport
go func() {
done <- advertiseForOS("darwin", ctx, func() Service { return svc }, api, func(r mdnsReport) {
mu.Lock()
report = r
mu.Unlock()
})
}()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && api.count() < 2 {
time.Sleep(10 * time.Millisecond)
}
if api.count() != 2 || called != 0 {
t.Fatalf("registered %d udp calls %d names %v", api.count(), called, api.names())
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
mu.Lock()
detail := report.Detail
mu.Unlock()
if report.Mode != "dns-sd" || !strings.Contains(detail, "_ipp._tcp,_universal,_print") || !strings.Contains(detail, "_verae._tcp") || strings.Contains(detail, "PTR _ipp._tcp") {
t.Fatalf("%+v", report)
}
if len(api.deregistered) != 2 {
t.Fatalf("deregistered %d", len(api.deregistered))
}
}
func TestDarwinDNSFailureUsesFullUDP(t *testing.T) {
pkt := &fakePkt{}
prev := listenMDNSFn
listenMDNSFn = func() (mdnsConn, error) { return pkt, nil }
t.Cleanup(func() { listenMDNSFn = prev })
api := &fakeDNS{err: errors.New("dns_sd unavailable")}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- advertiseForOS("darwin", ctx, func() Service { return sampleService() }, api, nil)
}()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && pkt.count() < 1 {
time.Sleep(10 * time.Millisecond)
}
if pkt.count() < 1 {
t.Fatal("no UDP announcement")
}
if err := assertPrimaryPTRs(pkt.packet(0), "Verae Timestamp"); err != nil {
t.Fatal(err)
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestMacAirDropAndPackageSources(t *testing.T) {
checks := []struct {
file string
need []string
}{
{"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}},
{"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}},
{"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint", "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print"}},
{"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint"}},
{"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "Verae NetPrint.app", "LSUIElement"}},
{"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop"}},
{"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild"}},
{"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}},
{"advertise_darwin.go", []string{`advertiseForOS("darwin"`}},
}
for _, c := range checks {
b, err := os.ReadFile(c.file)
if err != nil {
t.Fatal(err)
}
text := string(b)
for _, needle := range c.need {
if !strings.Contains(text, needle) {
t.Fatalf("%s missing %s", c.file, needle)
}
}
}
// The daemon publishes an event. It does not call the share sheet.
b, err := os.ReadFile("events.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "NSSharingService") || strings.Contains(string(b), "shareAirDrop") {
t.Fatal("job events must stay in the daemon")
}
}
func TestAirDropSettingRoundTrip(t *testing.T) {
cfg, path := testConfig(t)
s, err := Start(cfg, path, stampMock(t), &fakeCtrl{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = s.Shutdown(ctx)
})
c := NewClient(cfg.ControlPort)
if err := c.SetAirDropEnabled(true); err != nil {
t.Fatal(err)
}
st, err := c.Status()
if err != nil || !st.AirDropEnabled || st.Name != "Verae Timestamp" {
t.Fatalf("%+v %v", st, err)
}
saved, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(saved), `"airDropEnabled": true`) {
t.Fatal(string(saved))
}
var parsed Config
if err := json.Unmarshal(saved, &parsed); err != nil {
t.Fatal(err)
}
if !parsed.AirDropOn() || parsed.Name != "Verae Timestamp" {
t.Fatalf("%+v", parsed)
}
}