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>
363 lines
11 KiB
Go
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)
|
|
}
|
|
}
|