PrintToVeraeTimeStamp/internal/netprint/mdns_windows_test.go
Cursor Agent 1d8dbac81a
Advertise AirPrint subtypes and keep job folders, formats, and the service log consistent.
Windows DnsServiceRegister now registers the Bonjour service-type form and the RFC subtype name so a Mac browse of _ipp._tcp,_universal can see the printer. document-format-supported matches the TXT pdl list, including JPEG. Job ids persist in next-job and job-N directories. The service log is written and flushed even when stderr fails. The tray is started by Task Scheduler or Explorer so setup is not its parent.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 22:16:54 +00:00

414 lines
11 KiB
Go

package netprint
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
)
// fakeDNS is a DnsServiceRegister stand-in. CI runs on Linux, so the Windows
// strategy is invoked through advertiseForOS("windows", ..., fakeDNS).
type fakeDNS struct {
mu sync.Mutex
registered []dnsSDInstance
deregistered []dnsSDInstance
err error
failSubtypes bool
}
func (f *fakeDNS) Register(inst dnsSDInstance) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSubtypes && len(f.registered) > 0 {
return f.err
}
if f.err != nil && !f.failSubtypes {
return f.err
}
f.registered = append(f.registered, inst)
return nil
}
func (f *fakeDNS) Deregister(inst dnsSDInstance) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deregistered = append(f.deregistered, inst)
return nil
}
func (f *fakeDNS) names() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.registered))
for i, inst := range f.registered {
out[i] = inst.InstanceName
}
return out
}
func (f *fakeDNS) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.registered)
}
func sampleService() Service {
return Service{
Instance: "Verae Timestamp",
Host: "host",
Port: 631,
IPs: []net.IP{net.ParseIP("192.168.1.184")},
TXT: []string{"ty=Verae Timestamp", "rp=ipp/print"},
}
}
func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
listened := false
prev := listenMDNSFn
listenMDNSFn = func() (*net.UDPConn, error) {
listened = true
return nil, errors.New("UDP 5353 must not be bound when DnsServiceRegister succeeds")
}
t.Cleanup(func() { listenMDNSFn = prev })
api := &fakeDNS{}
var mu sync.Mutex
var got mdnsReport
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, func(r mdnsReport) {
mu.Lock()
got = r
mu.Unlock()
})
}()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && api.count() < 5 {
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("advertiseForOS did not return after cancel")
}
if listened {
t.Fatal("shared UDP 5353 was opened even though DnsServiceRegister succeeded")
}
mu.Lock()
report := got
mu.Unlock()
if report.Mode != "dns-sd" || !report.Advertising {
t.Fatalf("status report %+v", report)
}
if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") {
t.Fatalf("detail %q", report.Detail)
}
want := []string{
"Verae Timestamp._ipp._tcp.local",
"Verae Timestamp._ipp._tcp,_universal.local",
"Verae Timestamp._ipp._tcp,_print.local",
"Verae Timestamp._universal._sub._ipp._tcp.local",
"Verae Timestamp._print._sub._ipp._tcp.local",
}
gotNames := api.names()
if strings.Join(gotNames, "|") != strings.Join(want, "|") {
t.Fatalf("registered %v", gotNames)
}
api.mu.Lock()
defer api.mu.Unlock()
if len(api.registered) != 5 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" {
t.Fatalf("instance %+v", api.registered)
}
if len(api.deregistered) != 5 {
t.Fatalf("deregistered %d", len(api.deregistered))
}
if !strings.Contains(report.Detail, "_ipp._tcp,_universal") {
t.Fatalf("detail missing universal subtype %q", report.Detail)
}
}
func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) {
prev := listenMDNSFn
listenMDNSFn = func() (*net.UDPConn, error) {
return nil, errors.New("Only one usage of each socket address (protocol/network address/port) is normally permitted")
}
t.Cleanup(func() { listenMDNSFn = prev })
api := &fakeDNS{err: errors.New("dnsapi unavailable")}
err := advertiseForOS("windows", context.Background(), func() Service { return sampleService() }, api, nil)
if err == nil {
t.Fatal("expected a combined error")
}
msg := err.Error()
if !strings.Contains(msg, "DnsServiceRegister") || !strings.Contains(msg, "5353") {
t.Fatalf("error %q", msg)
}
if api.count() != 0 {
t.Fatal("primary registration should not be recorded after failure")
}
}
func TestWindowsSubtypeFailureStillAdvertises(t *testing.T) {
api := &fakeDNS{err: errors.New("subtype rejected"), failSubtypes: true}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil)
}()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && api.count() < 1 {
time.Sleep(10 * time.Millisecond)
}
if api.count() != 1 {
t.Fatalf("primary count %d", api.count())
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestWindowsAdvertiseEntrypoint(t *testing.T) {
b, err := os.ReadFile("advertise_windows.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
if !strings.Contains(src, `advertiseForOS("windows"`) || !strings.Contains(src, "windowsDNSService{}") {
t.Fatal("Windows advertisePlatform must call DnsServiceRegister via advertiseForOS")
}
}
func TestStatusShowsWindowsDNSDSuccess(t *testing.T) {
api := &fakeDNS{}
restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error {
return advertiseForOS("windows", ctx, current, api, sink)
})
defer restore()
prevListen := listenMDNSFn
listenMDNSFn = func() (*net.UDPConn, error) {
t.Errorf("UDP listen called")
return nil, errors.New("should not bind")
}
defer func() { listenMDNSFn = prevListen }()
cfg, path := testConfig(t)
on := true
cfg.Advertise = &on
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)
})
st := waitMDNS(t, s, "dns-sd")
if !st.MDNSAdvertising || st.MDNSError != "" {
t.Fatalf("%+v", st)
}
if api.count() < 1 || !strings.Contains(api.names()[0], "._ipp._tcp.local") {
t.Fatalf("Windows path was not invoked: %v", api.names())
}
body := getURL(t, s.ControlURL()+"api/v1/status")
for _, needle := range []string{`"mdnsAdvertising": true`, `"mdnsMode": "dns-sd"`} {
if !strings.Contains(body, needle) {
t.Fatalf("status missing %s\n%s", needle, body)
}
}
}
func TestStatusShowsMDNSFailure(t *testing.T) {
restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error {
return advertiseForOS("windows", ctx, current, &fakeDNS{err: errors.New("dnsapi unavailable")}, sink)
})
defer restore()
prevListen := listenMDNSFn
listenMDNSFn = func() (*net.UDPConn, error) {
return nil, errors.New("Only one usage of each socket address")
}
defer func() { listenMDNSFn = prevListen }()
cfg, path := testConfig(t)
on := true
cfg.Advertise = &on
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)
})
st := waitMDNS(t, s, "error")
if st.MDNSAdvertising {
t.Fatal("advertising should be false")
}
if !strings.Contains(st.MDNSError, "DnsServiceRegister") || !strings.Contains(st.MDNSError, "5353") {
t.Fatalf("mdnsError %q", st.MDNSError)
}
body := getURL(t, s.ControlURL()+"api/v1/status")
if !strings.Contains(body, `"mdnsMode": "error"`) || !strings.Contains(body, "DnsServiceRegister") {
t.Fatalf("status API hid the mDNS error\n%s", body)
}
}
func TestServiceLogWritesStartupAndJobs(t *testing.T) {
dir := t.TempDir()
t.Setenv("VERAE_LOG_DIR", dir)
prevW := log.Writer()
prevFlags := log.Flags()
t.Cleanup(func() {
log.SetOutput(prevW)
log.SetFlags(prevFlags)
})
setupServiceLog()
log.Printf("netprint: starting")
st, err := OpenStore(dir)
if err != nil {
t.Fatal(err)
}
ProcessJob(st, Config{}, nil, 7, "application/pdf", "Hello", "ada", []byte("%PDF-1.4"), func(string, string, string) (job.Result, error) {
return job.Result{}, fmt.Errorf("boom")
}, nil)
b, err := os.ReadFile(filepath.Join(dir, "netprint.log"))
if err != nil {
t.Fatal(err)
}
text := string(b)
for _, needle := range []string{"netprint: log file", "netprint: starting", "job 7 started", "job 7 failed"} {
if !strings.Contains(text, needle) {
t.Fatalf("log missing %q\n%s", needle, text)
}
}
}
func TestTrayDiscoveryAndPublicWarning(t *testing.T) {
st := Status{
Running: true, Name: "Verae Timestamp", Port: 631,
MDNSMode: "error", MDNSError: "DnsServiceRegister: dnsapi unavailable; shared UDP 5353: bind failed",
NetworkPublic: true, SupportsPublic: true,
}
var sawDiscovery, sawWarn, sawPublic bool
for _, e := range BuildMenu(st) {
if strings.HasPrefix(e.Label, "Discovery — not advertising:") {
sawDiscovery = true
if !strings.Contains(e.Label, "DnsServiceRegister") {
t.Fatal(e.Label)
}
}
if strings.Contains(e.Label, "This network is Public") {
sawWarn = true
}
if e.Kind == "public" {
sawPublic = true
if !e.Enabled || e.Checked {
t.Fatalf("public entry %+v", e)
}
}
}
if !sawDiscovery || !sawWarn || !sawPublic {
t.Fatalf("discovery %v warn %v public %v", sawDiscovery, sawWarn, sawPublic)
}
if firewallProfile(false) != "private,domain" || firewallProfile(true) != "private,domain,public" {
t.Fatalf("profiles %q %q", firewallProfile(false), firewallProfile(true))
}
}
func TestAllowPublicRoundTrip(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)
})
res, err := httpPost(t, s.ControlURL()+"api/v1/firewall", `{"allowPublic":true}`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(res, `"allowPublic": true`) {
t.Fatal(res)
}
body := getURL(t, s.ControlURL()+"api/v1/status")
if !strings.Contains(body, `"allowPublic": true`) {
t.Fatal(body)
}
}
func swapAdvertiser(t *testing.T, fn func(context.Context, func() Service, mdnsSink) error) func() {
t.Helper()
prev := startAdvertiser
startAdvertiser = fn
return func() { startAdvertiser = prev }
}
func waitMDNS(t *testing.T, s *Server, mode string) Status {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
var st Status
for time.Now().Before(deadline) {
st = s.status()
if st.MDNSMode == mode {
return st
}
time.Sleep(15 * time.Millisecond)
}
t.Fatalf("mdns mode %q, want %q (%+v)", st.MDNSMode, mode, st)
return st
}
func getURL(t *testing.T, raw string) string {
t.Helper()
res, err := http.Get(raw)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("GET %s: %d %s", raw, res.StatusCode, b)
}
return string(b)
}
func httpPost(t *testing.T, raw, body string) (string, error) {
t.Helper()
res, err := http.Post(raw, "application/json", strings.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
if err != nil {
return "", err
}
if res.StatusCode >= 300 {
return "", fmt.Errorf("POST %s: %d %s", raw, res.StatusCode, b)
}
return string(b), nil
}