438 lines
16 KiB
Go
438 lines
16 KiB
Go
package main
|
|
|
|
// notify_test.go — the notification rules, run on the platform the suite
|
|
// actually runs on.
|
|
//
|
|
// The Win32 calls in notify_windows.go cannot be exercised here and nobody on
|
|
// this project has a Windows desktop to exercise them on. What CAN be checked
|
|
// anywhere is the part that decides WHETHER to interrupt someone, which is where
|
|
// every interesting failure lives: a prompt that fires twelve times a minute is
|
|
// worse than the silence it replaced, because the user turns notifications off
|
|
// and never turns them back on. So the clock is injected and every rule below is
|
|
// a sequence of ticks, not a mock of the shell.
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
// recordingSink stands in for the notification area.
|
|
type recordingSink struct {
|
|
tips []string
|
|
notices []uiNotice
|
|
hides int
|
|
}
|
|
|
|
func (r *recordingSink) show(tip string, n *uiNotice) {
|
|
r.tips = append(r.tips, tip)
|
|
if n != nil {
|
|
r.notices = append(r.notices, *n)
|
|
}
|
|
}
|
|
|
|
func (r *recordingSink) hide() { r.hides++ }
|
|
|
|
func notifyAsk(id, host, line string) uiApproval {
|
|
return uiApproval{ID: id, Short: id, Host: host, Lines: []string{line}}
|
|
}
|
|
|
|
// notifyT0 is an arbitrary fixed instant. Nothing here depends on the wall clock.
|
|
var notifyT0 = time.Date(2026, 9, 13, 11, 0, 0, 0, time.UTC)
|
|
|
|
// THE ONE THE WHOLE FEATURE RESTS ON. The tray re-reads the machine's state
|
|
// every trayRefresh = 5 s and the request stays pending for as long as 24 h
|
|
// (stepupRequestTTL), so a notifier that announced what it saw would announce
|
|
// the same folder request seventeen thousand times.
|
|
func TestAPendingRequestIsAnnouncedExactlyOnce(t *testing.T) {
|
|
var n approvalNotifier
|
|
sink := &recordingSink{}
|
|
pending := []uiApproval{notifyAsk("a1", "member", "Your host asks to reach the folder C:\\Docs")}
|
|
|
|
for i := 0; i < 40; i++ { // 40 ticks is well past approvalNotifyMinGap
|
|
n.poll(notifyT0.Add(time.Duration(i)*trayRefresh), pending, sink)
|
|
}
|
|
if len(sink.notices) != 1 {
|
|
t.Fatalf("announced %d times over 40 polls, want exactly 1: %+v", len(sink.notices), sink.notices)
|
|
}
|
|
if len(sink.tips) != 40 {
|
|
t.Errorf("the waiting icon was updated %d times, want one per poll", len(sink.tips))
|
|
}
|
|
if sink.hides != 0 {
|
|
t.Errorf("the icon was taken down %d times while a request was still pending", sink.hides)
|
|
}
|
|
}
|
|
|
|
// "Never notify for a request the user has already answered" — and the icon has
|
|
// to come down with it, or the machine goes on claiming work that is done.
|
|
func TestAnAnsweredRequestIsNeverAnnouncedAgain(t *testing.T) {
|
|
var n approvalNotifier
|
|
sink := &recordingSink{}
|
|
a := []uiApproval{notifyAsk("a1", "member", "Your host asks to reach the folder C:\\Docs")}
|
|
|
|
n.poll(notifyT0, a, sink)
|
|
if len(sink.notices) != 1 {
|
|
t.Fatalf("the first sighting must announce, got %d", len(sink.notices))
|
|
}
|
|
// Answered on the page: pendingApprovals stops returning it.
|
|
for i := 1; i < 30; i++ {
|
|
n.poll(notifyT0.Add(time.Duration(i)*trayRefresh), nil, sink)
|
|
}
|
|
if len(sink.notices) != 1 {
|
|
t.Errorf("announced again after it was answered: %+v", sink.notices[1:])
|
|
}
|
|
if sink.hides == 0 {
|
|
t.Error("the waiting icon was never taken down after the last request was answered")
|
|
}
|
|
}
|
|
|
|
// "Do not storm the user when three arrive at once." Three in one poll is ONE
|
|
// notification, and it says three.
|
|
func TestThreeArrivingAtOnceAreOneNotification(t *testing.T) {
|
|
var n approvalNotifier
|
|
sink := &recordingSink{}
|
|
n.poll(notifyT0, []uiApproval{
|
|
notifyAsk("a1", "member", "Your host asks to reach the folder C:\\Docs"),
|
|
notifyAsk("a2", "member", "Your host asks to reach the folder C:\\Pics"),
|
|
notifyAsk("a3", "rex", "Your host asks to MOVE 4 item(s) inside C:\\Docs"),
|
|
}, sink)
|
|
|
|
if len(sink.notices) != 1 {
|
|
t.Fatalf("want 1 notification for 3 simultaneous requests, got %d", len(sink.notices))
|
|
}
|
|
got := sink.notices[0]
|
|
if !strings.Contains(got.Title, "3 requests") {
|
|
t.Errorf("title %q does not say how many are waiting", got.Title)
|
|
}
|
|
if !strings.Contains(got.Body, "member and rex") {
|
|
t.Errorf("body %q does not name both hosts", got.Body)
|
|
}
|
|
if tip := sink.tips[0]; !strings.Contains(tip, "3 requests") {
|
|
t.Errorf("tooltip %q does not carry the count", tip)
|
|
}
|
|
}
|
|
|
|
// Three arriving on three consecutive ticks is the same storm spread out. They
|
|
// are HELD, and held is not dropped: the one that was suppressed goes out in the
|
|
// next notification and the count is right when it does.
|
|
func TestArrivalsInsideTheGapAreHeldAndThenSaidTogether(t *testing.T) {
|
|
var n approvalNotifier
|
|
sink := &recordingSink{}
|
|
a1 := notifyAsk("a1", "member", "one")
|
|
a2 := notifyAsk("a2", "member", "two")
|
|
a3 := notifyAsk("a3", "member", "three")
|
|
|
|
n.poll(notifyT0, []uiApproval{a1}, sink)
|
|
n.poll(notifyT0.Add(trayRefresh), []uiApproval{a1, a2}, sink)
|
|
n.poll(notifyT0.Add(2*trayRefresh), []uiApproval{a1, a2, a3}, sink)
|
|
if len(sink.notices) != 1 {
|
|
t.Fatalf("%d notifications inside approvalNotifyMinGap, want 1", len(sink.notices))
|
|
}
|
|
|
|
// Past the gap, with nothing new since: the two that were held go out.
|
|
n.poll(notifyT0.Add(approvalNotifyMinGap+time.Second), []uiApproval{a1, a2, a3}, sink)
|
|
if len(sink.notices) != 2 {
|
|
t.Fatalf("the held requests were never announced: %d notifications", len(sink.notices))
|
|
}
|
|
if !strings.Contains(sink.notices[1].Title, "3 requests") {
|
|
t.Errorf("second notification says %q; it must count everything waiting, not only what is new",
|
|
sink.notices[1].Title)
|
|
}
|
|
|
|
// And now nothing is unannounced, so the gap expiring again says nothing.
|
|
n.poll(notifyT0.Add(4*approvalNotifyMinGap), []uiApproval{a1, a2, a3}, sink)
|
|
if len(sink.notices) != 2 {
|
|
t.Errorf("announced %d times with nothing new", len(sink.notices))
|
|
}
|
|
}
|
|
|
|
// THE MEASURED HAZARD. requestsLoad returns an EMPTY MAP on any read or
|
|
// unmarshal failure and reports no error (stepup.go:562-567), and
|
|
// collectUIStatus returns with no approvals at all when loadPairings fails
|
|
// (ui.go:140-145) — so "the list went empty" does not mean "the user answered
|
|
// it". A Windows virus scanner holding the file open for a moment is enough. If
|
|
// the memory of an id were tied to it staying visible, the blip would announce
|
|
// the same request twice.
|
|
//
|
|
// The gap is deliberately stepped past, so this proves the id is remembered
|
|
// rather than that the timer happened to suppress it.
|
|
func TestATransientlyEmptyListDoesNotAnnounceTheSameRequestTwice(t *testing.T) {
|
|
var n approvalNotifier
|
|
sink := &recordingSink{}
|
|
a := []uiApproval{notifyAsk("a1", "member", "Your host asks to reach the folder C:\\Docs")}
|
|
|
|
n.poll(notifyT0, a, sink)
|
|
n.poll(notifyT0.Add(trayRefresh), nil, sink) // the blip
|
|
n.poll(notifyT0.Add(2*trayRefresh), a, sink) // and back
|
|
|
|
// Well past approvalNotifyMinGap, so nothing here is the gap timer's doing.
|
|
n.poll(notifyT0.Add(approvalNotifyMinGap+time.Minute), a, sink)
|
|
if len(sink.notices) != 1 {
|
|
t.Fatalf("announced %d times across a transient empty read, want 1: %+v",
|
|
len(sink.notices), sink.notices)
|
|
}
|
|
}
|
|
|
|
// The memory has to be bounded, and the bound is the request's own lifetime:
|
|
// requestsLoad deletes anything older than stepupRequestTTL on every read
|
|
// (stepup.go:562-573), so an id gone for longer than that can never come back.
|
|
// Shorter than that would re-announce across a long outage; unbounded would grow
|
|
// a map for the life of the daemon.
|
|
func TestAnIdIsRememberedForAsLongAsItsRequestCouldExist(t *testing.T) {
|
|
var n approvalNotifier
|
|
sink := &recordingSink{}
|
|
a := []uiApproval{notifyAsk("a1", "member", "Your host asks to reach the folder C:\\Docs")}
|
|
|
|
n.poll(notifyT0, a, sink)
|
|
// Gone for just under the request's lifetime, then back: still known.
|
|
n.poll(notifyT0.Add(approvalForget-time.Minute), nil, sink)
|
|
n.poll(notifyT0.Add(approvalForget-time.Minute+trayRefresh), a, sink)
|
|
if len(sink.notices) != 1 {
|
|
t.Fatalf("re-announced inside approvalForget: %d notifications", len(sink.notices))
|
|
}
|
|
if _, ok := n.seen["a1"]; !ok {
|
|
t.Fatal("the id was dropped from memory while it could still be pending")
|
|
}
|
|
|
|
// Past it, the id is dropped and the map does not grow for ever.
|
|
n.poll(notifyT0.Add(3*approvalForget), nil, sink)
|
|
if _, ok := n.seen["a1"]; ok {
|
|
t.Error("the id is still held long after the request it names expired (stepup.go:569)")
|
|
}
|
|
}
|
|
|
|
// A request the machine cannot put into words gets different words. ui.go:221-234
|
|
// explains why the PAGE changes shape for these; a notification that read like
|
|
// any other would undo that before the user ever reached the page.
|
|
func TestAnUndescribableRequestSaysSoInTheNotification(t *testing.T) {
|
|
n := approvalNoticeFor([]uiApproval{{
|
|
ID: "a1", Host: "member", Lines: []string{uiUnknownRequest}, Unreadable: true,
|
|
}})
|
|
if !strings.Contains(n.Body, "cannot describe") {
|
|
t.Errorf("body %q does not say the machine cannot describe the request", n.Body)
|
|
}
|
|
if !strings.Contains(n.Body, "refusing is the safe answer") {
|
|
t.Errorf("body %q does not point at the safe direction", n.Body)
|
|
}
|
|
if strings.Contains(n.Body, uiUnknownRequest) {
|
|
t.Errorf("body %q shows the raw %q placeholder", n.Body, uiUnknownRequest)
|
|
}
|
|
}
|
|
|
|
func TestTheSingleRequestNoticeNamesTheHostAndWhatIsAsked(t *testing.T) {
|
|
n := approvalNoticeFor([]uiApproval{
|
|
notifyAsk("a1", "member", "Your host asks to reach the folder C:\\Users\\user\\Documents"),
|
|
})
|
|
for _, want := range []string{"C:\\Users\\user\\Documents", "member"} {
|
|
if !strings.Contains(n.Body, want) {
|
|
t.Errorf("body %q does not contain %q", n.Body, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// NOTIFYICONDATAW carries fixed WCHAR arrays — szInfoTitle[64], szInfo[256] —
|
|
// and the shell reads each to its first NUL. Text that does not fit is not
|
|
// shortened for you.
|
|
//
|
|
// THE FIRST VERSION OF THIS TEST WAS TAUTOLOGICAL and the mutation check is what
|
|
// said so: its only multi-request case had twelve hosts, which takes the "from
|
|
// 12 hosts" branch and produces a SHORT body, so deleting the clip on that
|
|
// branch left the test green. The case that matters is two or three hosts with
|
|
// long names, because that is the branch that spells every name out.
|
|
func TestEveryNoticeFitsTheWindowsFields(t *testing.T) {
|
|
long := strings.Repeat("Ünmöglich-lange-Ördnernamen/", 40)
|
|
many := []uiApproval{}
|
|
for i := 0; i < 12; i++ {
|
|
many = append(many, notifyAsk(fmt.Sprintf("id%d", i), long+fmt.Sprintf("%d", i), long))
|
|
}
|
|
cases := map[string][]uiApproval{
|
|
"one long request": {notifyAsk("a1", long, long)},
|
|
"twelve hosts": many,
|
|
"one unreadable": {{ID: "a1", Host: long, Unreadable: true}},
|
|
"three hosts, every name spelled out": {
|
|
notifyAsk("a1", long+"1", "one"),
|
|
notifyAsk("a2", long+"2", "two"),
|
|
notifyAsk("a3", long+"3", "three"),
|
|
},
|
|
}
|
|
for name, in := range cases {
|
|
n := approvalNoticeFor(in)
|
|
if got := utf16Len(n.Title); got > noticeTitleMax {
|
|
t.Errorf("%s: title is %d UTF-16 units, szInfoTitle holds %d plus a NUL", name, got, noticeTitleMax)
|
|
}
|
|
if got := utf16Len(n.Body); got > noticeBodyMax {
|
|
t.Errorf("%s: body is %d UTF-16 units, szInfo holds %d plus a NUL", name, got, noticeBodyMax)
|
|
}
|
|
}
|
|
if got := utf16Len(approvalTip(999999)); got > noticeTipMax {
|
|
t.Errorf("tooltip is %d UTF-16 units, szTip holds %d plus a NUL", got, noticeTipMax)
|
|
}
|
|
}
|
|
|
|
// A folder name outside the Basic Multilingual Plane is ONE rune and TWO UTF-16
|
|
// units. Counting in runes overruns the array; cutting between the halves of a
|
|
// surrogate pair produces something that is not a character. Folder names carry
|
|
// emoji, and the body of a notification is a path the host sent.
|
|
func TestClipUTF16CountsUnitsAndNeverSplitsAPair(t *testing.T) {
|
|
rocket := "\U0001F680" // 2 UTF-16 units, 1 rune, 4 bytes
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
max int
|
|
}{
|
|
{"all astral", strings.Repeat(rocket, 20), 11},
|
|
{"cut lands mid-pair", "abc" + strings.Repeat(rocket, 10), 6},
|
|
{"mixed", "C:\\Fotos " + strings.Repeat(rocket, 10) + " 2026", 15},
|
|
{"max of one", strings.Repeat(rocket, 4), 1},
|
|
{"already fits", "short", 40},
|
|
}
|
|
for _, c := range cases {
|
|
got := clipUTF16(c.in, c.max)
|
|
if n := utf16Len(got); n > c.max {
|
|
t.Errorf("%s: clipped to %d units, asked for at most %d (%q)", c.name, n, c.max, got)
|
|
}
|
|
for i, r := range got {
|
|
if r == '\uFFFD' {
|
|
t.Errorf("%s: a replacement character at byte %d — a pair was split: %q",
|
|
c.name, i, got)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if got := clipUTF16("short", 40); got != "short" {
|
|
t.Errorf("text that fits was changed to %q", got)
|
|
}
|
|
if got := clipUTF16(strings.Repeat("a", 50), 10); !strings.HasSuffix(got, "…") {
|
|
t.Errorf("clipped text %q does not show that it was cut", got)
|
|
}
|
|
}
|
|
|
|
// putUTF16 is the last line of defence: whatever clipUTF16 did, what reaches the
|
|
// struct must be NUL-terminated and must not end on half a surrogate pair,
|
|
// because the shell decodes it and the array has no room to spare.
|
|
func TestPutUTF16TerminatesAndLeavesNoHalfCharacter(t *testing.T) {
|
|
rocket := "\U0001F680"
|
|
for _, c := range []struct {
|
|
name string
|
|
in string
|
|
size int
|
|
}{
|
|
{"exactly fits", "abcd", 5},
|
|
{"one too long", "abcde", 5},
|
|
{"astral overflow", strings.Repeat(rocket, 8), 6},
|
|
{"cut lands mid-pair", "ab" + strings.Repeat(rocket, 8), 5},
|
|
{"empty", "", 4},
|
|
} {
|
|
dst := make([]uint16, c.size)
|
|
for i := range dst {
|
|
dst[i] = 0xBEEF // poison, so a missing NUL is visible
|
|
}
|
|
putUTF16(dst, c.in)
|
|
|
|
nul := -1
|
|
for i, u := range dst {
|
|
if u == 0 {
|
|
nul = i
|
|
break
|
|
}
|
|
}
|
|
if nul < 0 {
|
|
t.Errorf("%s: no NUL anywhere in the array — the shell reads past the end", c.name)
|
|
continue
|
|
}
|
|
s := string(utf16.Decode(dst[:nul]))
|
|
if strings.ContainsRune(s, '\uFFFD') {
|
|
t.Errorf("%s: decoded to %q — an unpaired surrogate was left in the array", c.name, s)
|
|
}
|
|
if !strings.HasPrefix(c.in, s) {
|
|
t.Errorf("%s: wrote %q, which is not a prefix of %q", c.name, s, c.in)
|
|
}
|
|
}
|
|
putUTF16(nil, "anything") // must not panic
|
|
}
|
|
|
|
func TestJoinNames(t *testing.T) {
|
|
cases := []struct {
|
|
in []string
|
|
want string
|
|
}{
|
|
{nil, ""},
|
|
{[]string{"member"}, "member"},
|
|
{[]string{"member", "rex"}, "member and rex"},
|
|
{[]string{"member", "rex", "ada"}, "member, rex and ada"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := joinNames(c.in); got != c.want {
|
|
t.Errorf("joinNames(%v) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// THE GUARD ON THE OTHER HALF. guardSingleRun's refusals go to stderr and then
|
|
// exit 1, which is nothing at all when the process has no console — the state
|
|
// 0.24.0 made normal by relaunching DETACHED (relaunch_windows.go:46-48). Every
|
|
// error return has to go through refuseVisibly, and the branch someone adds next
|
|
// year has to as well, which is what this reads the source to check.
|
|
//
|
|
// It reads the file from disk rather than being compiled into a Windows build,
|
|
// for the same reason console_test.go does: the platform that runs the suite is
|
|
// Linux.
|
|
func TestEveryRunGuardRefusalIsVisible(t *testing.T) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, "runguard_windows.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fn *ast.FuncDecl
|
|
for _, d := range f.Decls {
|
|
if g, ok := d.(*ast.FuncDecl); ok && g.Recv == nil && g.Name.Name == "guardSingleRun" {
|
|
fn = g
|
|
}
|
|
}
|
|
if fn == nil {
|
|
t.Fatal("guardSingleRun is not in runguard_windows.go any more — this check would " +
|
|
"pass for ever while the refusal went silent again")
|
|
}
|
|
|
|
returns, guarded := 0, 0
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
ret, ok := n.(*ast.ReturnStmt)
|
|
if !ok || len(ret.Results) != 1 {
|
|
return true
|
|
}
|
|
if id, ok := ret.Results[0].(*ast.Ident); ok && id.Name == "nil" {
|
|
return true // the success path
|
|
}
|
|
returns++
|
|
call, ok := ret.Results[0].(*ast.CallExpr)
|
|
if !ok {
|
|
t.Errorf("runguard_windows.go:%d — this refusal is returned straight to main, which "+
|
|
"prints it to stderr. A bastion started by double-clicking the exe, or relaunched "+
|
|
"DETACHED after a self-update, has no console to print it into. Wrap it: "+
|
|
"`return refuseVisibly(<title>, <err>)`.", fset.Position(ret.Pos()).Line)
|
|
return true
|
|
}
|
|
if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "refuseVisibly" {
|
|
t.Errorf("runguard_windows.go:%d — this refusal does not go through refuseVisibly, so "+
|
|
"it is invisible on a console-less start. See the comment above it.",
|
|
fset.Position(ret.Pos()).Line)
|
|
return true
|
|
}
|
|
guarded++
|
|
return true
|
|
})
|
|
|
|
if returns < 3 {
|
|
t.Fatalf("only %d refusal(s) examined in guardSingleRun — this check has stopped "+
|
|
"finding them, which makes it a green light that proves nothing", returns)
|
|
}
|
|
if guarded != returns {
|
|
t.Errorf("%d of %d refusals are shown to the user", guarded, returns)
|
|
}
|
|
}
|