// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. package notify import ( "encoding/json" "os" "path/filepath" "testing" "time" "github.com/godbus/dbus/v5" ) // The integration tests need a session bus this process can own the name on. // Run them with dbus-run-session -- go test ./internal/notify func busOrSkip(t *testing.T) *dbus.Conn { t.Helper() conn, err := dbus.SessionBus() if err != nil { t.Skipf("no session bus: %v", err) } reply, err := conn.RequestName(Name, dbus.NameFlagDoNotQueue) if err != nil { t.Skipf("cannot request the name: %v", err) } // AlreadyOwner happens on the second bus test in one process, because // dbus.SessionBus is a shared connection. That is fine: the name is ours. if reply != dbus.RequestNameReplyPrimaryOwner && reply != dbus.RequestNameReplyAlreadyOwner { t.Skipf("cannot own the name, run under dbus-run-session (reply %v)", reply) } return conn } func TestIdentityAndCapabilities(t *testing.T) { s := &Service{} name, vendor, _, spec, err := s.GetServerInformation() if err != nil { t.Fatalf("GetServerInformation: %v", err) } if name != "danix" || vendor != "danix" || spec != "1.2" { t.Errorf("identity = %q/%q spec %q, want danix/danix 1.2", name, vendor, spec) } caps, err := s.GetCapabilities() if err != nil { t.Fatalf("GetCapabilities: %v", err) } for _, want := range []string{"actions", "body-markup", "icon-static", "persistence"} { found := false for _, c := range caps { if c == want { found = true } } if !found { t.Errorf("missing capability %q in %v", want, caps) } } } func TestCapabilitiesIncludeBodyImages(t *testing.T) { caps, err := NewService(nil, t.TempDir()).GetCapabilities() if err != nil { t.Fatal(err) } found := false for _, c := range caps { if c == "body-images" { found = true } } if !found { t.Fatalf("body-images missing from %v", caps) } } func TestNotifyPublishesImagePath(t *testing.T) { var live []Popup s := &Service{dir: t.TempDir(), timers: map[uint32]*time.Timer{}} s.store = NewStore(s.emitClosed, func(l, _ []Popup) { live = l }, s.removeImage) hints := map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")} if _, err := s.Notify("t", 0, "", "s", "b", nil, hints, -1); err != nil { t.Fatal(err) } if len(live) != 1 || live[0].Image != "/tmp/shot.png" { t.Fatalf("published %+v", live) } } func TestNotifyReturnsAnIDAndPublishes(t *testing.T) { conn := busOrSkip(t) dir := t.TempDir() svc := NewService(conn, dir) if err := svc.Start(); err != nil { t.Fatalf("Start: %v", err) } obj := conn.Object(Name, dbus.ObjectPath(objPath)) call := obj.Call(iface+".Notify", 0, "app", uint32(0), "icon", "summary", "body", []string{"default", "open"}, map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}, int32(-1)) if call.Err != nil { t.Fatalf("Notify: %v", call.Err) } var id uint32 if err := call.Store(&id); err != nil { t.Fatalf("Notify reply: %v", err) } if id == 0 { t.Fatal("Notify returned id 0") } data, err := os.ReadFile(filepath.Join(dir, "queue.json")) if err != nil { t.Fatalf("read queue: %v", err) } var live []Popup if err := json.Unmarshal(data, &live); err != nil { t.Fatalf("queue not JSON: %v", err) } if len(live) != 1 || live[0].Summary != "summary" { t.Fatalf("queue = %+v, want one summary", live) } if live[0].Expires == 0 { t.Error("expires is zero, want a normal timeout") } } func TestCloseNotificationEmitsReasonThree(t *testing.T) { conn := busOrSkip(t) dir := t.TempDir() svc := NewService(conn, dir) if err := svc.Start(); err != nil { t.Fatalf("Start: %v", err) } signals := make(chan *dbus.Signal, 4) conn.Signal(signals) if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(objPath))); err != nil { t.Fatalf("AddMatchSignal: %v", err) } obj := conn.Object(Name, dbus.ObjectPath(objPath)) call := obj.Call(iface+".Notify", 0, "app", uint32(0), "", "s", "b", []string{}, map[string]dbus.Variant{}, int32(-1)) var id uint32 if err := call.Store(&id); err != nil { t.Fatalf("Notify reply: %v", err) } if err := obj.Call(iface+".CloseNotification", 0, id).Err; err != nil { t.Fatalf("CloseNotification: %v", err) } select { case sig := <-signals: if sig.Name != iface+".NotificationClosed" { t.Fatalf("signal %q, want NotificationClosed", sig.Name) } if sig.Body[0].(uint32) != id || sig.Body[1].(uint32) != 3 { t.Errorf("signal body = %v, want id %d reason 3", sig.Body, id) } case <-time.After(2 * time.Second): t.Fatal("no NotificationClosed signal") } } func TestRemoveImageRefusesPathsOutsideTheImageDir(t *testing.T) { dir := t.TempDir() imgDir := ImagesDir(dir) if err := os.MkdirAll(imgDir, 0o700); err != nil { t.Fatal(err) } sibling := filepath.Join(dir, "queue.json") if err := os.WriteFile(sibling, []byte("x"), 0o600); err != nil { t.Fatal(err) } owned := filepath.Join(imgDir, "1.png") if err := os.WriteFile(owned, []byte("x"), 0o600); err != nil { t.Fatal(err) } s := &Service{dir: dir} s.removeImage(filepath.Join(imgDir, "..", "queue.json")) if _, err := os.Stat(sibling); err != nil { t.Fatalf("traversal removed a sibling: %v", err) } s.removeImage(sibling) if _, err := os.Stat(sibling); err != nil { t.Fatalf("unrelated path removed a sibling: %v", err) } s.removeImage("") if _, err := os.Stat(sibling); err != nil { t.Fatalf("empty path removed a sibling: %v", err) } s.removeImage(owned) if _, err := os.Stat(owned); !os.IsNotExist(err) { t.Fatalf("owned image not removed: %v", err) } } // A natural expiry must drop the timer entry, or the map grows by one timer per // id for the whole session. No bus is needed: the store's emit is a no-op here, // so the test exercises arm's closure directly. func TestNaturalExpiryDeletesTheTimerEntry(t *testing.T) { s := &Service{dir: t.TempDir(), timers: map[uint32]*time.Timer{}} s.store = NewStore(func(id, reason uint32) {}, func(live, history []Popup) {}, nil) id, err := s.Notify("app", 0, "", "s", "b", nil, nil, 50) if err != nil { t.Fatalf("Notify: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { s.mu.Lock() _, present := s.timers[id] s.mu.Unlock() if !present { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("timer entry for id %d still present after expiry", id) }