Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

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)
}
}