375 lines
10 KiB
Go
375 lines
10 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func test_client(t *testing.T, namespace string) *Client {
|
|
t.Helper()
|
|
c := New(context.Background(), os.Geteuid(), namespace)
|
|
t.Cleanup(c.Close)
|
|
return c
|
|
}
|
|
|
|
func wait_history(t *testing.T, c *Client, want int) Snapshot {
|
|
t.Helper()
|
|
deadline := time.NewTimer(5 * time.Second)
|
|
defer deadline.Stop()
|
|
for {
|
|
if state := c.Snapshot(); len(state.Entries) == want {
|
|
return state
|
|
}
|
|
select {
|
|
case <-c.Updates():
|
|
case <-deadline.C:
|
|
t.Fatalf("history has %d entries, want %d", len(c.Snapshot().Entries), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSharedHistoryConcurrentPublishingBoundAndSingleCue(t *testing.T) {
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
var clients []*Client
|
|
for range 4 {
|
|
clients = append(clients, test_client(t, namespace))
|
|
}
|
|
var workers sync.WaitGroup
|
|
for index, client := range clients {
|
|
workers.Go(func() {
|
|
for count := range 30 {
|
|
client.Publish(fmt.Sprintf("Reader %d message %d", index, count), time.Now())
|
|
}
|
|
})
|
|
}
|
|
workers.Wait()
|
|
for _, client := range clients {
|
|
wait_history(t, client, Limit)
|
|
}
|
|
// Reconcile after reaching the limit: all replicas must agree on the same
|
|
// newest 100 IDs rather than just having the same length.
|
|
for range 3 {
|
|
for _, client := range clients {
|
|
if _, err := client.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
want := clients[0].Snapshot().Entries
|
|
for _, client := range clients[1:] {
|
|
if !reflect.DeepEqual(client.Snapshot().Entries, want) {
|
|
t.Fatal("readers disagree about bounded history")
|
|
}
|
|
}
|
|
accepted := make(chan bool, len(clients))
|
|
for _, client := range clients {
|
|
workers.Go(func() { accepted <- client.Claim_sound(context.Background()) })
|
|
}
|
|
workers.Wait()
|
|
close(accepted)
|
|
count := 0
|
|
for allowed := range accepted {
|
|
if allowed {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Fatal("multiple readers admitted the notification cue", count)
|
|
}
|
|
for _, client := range clients {
|
|
if client.Claim_sound(context.Background()) {
|
|
t.Fatal("a repeated cue escaped the shared cooldown")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCoordinatorTakeoverAndHistoryLifetime(t *testing.T) {
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
owner := test_client(t, namespace)
|
|
owner.Publish("Before takeover", time.Now())
|
|
if !owner.Claim_sound(context.Background()) {
|
|
t.Fatal("first cue was rejected")
|
|
}
|
|
survivor := test_client(t, namespace)
|
|
wait_history(t, survivor, 1)
|
|
// Synchronize the cue timestamp before terminating the coordinator.
|
|
if _, err := survivor.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owner.Close()
|
|
if survivor.Claim_sound(context.Background()) {
|
|
t.Fatal("coordinator exit reset the cooldown")
|
|
}
|
|
survivor.Publish("After takeover", time.Now())
|
|
if _, err := survivor.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joining := test_client(t, namespace)
|
|
wait_history(t, joining, 2)
|
|
survivor.Close()
|
|
joining.Close()
|
|
fresh := test_client(t, namespace)
|
|
if _, err := fresh.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fresh.Snapshot().Entries) != 0 || !fresh.Claim_sound(context.Background()) {
|
|
t.Fatal("history or cooldown survived the last reader")
|
|
}
|
|
}
|
|
|
|
func TestCoordinatorExitFlushesNotificationToSurvivingReader(t *testing.T) {
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
owner := test_client(t, namespace)
|
|
if _, err := owner.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
survivor := test_client(t, namespace)
|
|
if _, err := survivor.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owner.Publish("Immediately before exit", time.Now())
|
|
owner.Close()
|
|
wait_history(t, survivor, 1)
|
|
if _, err := survivor.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := survivor.Snapshot().Entries[0].Text; got != "Immediately before exit" {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestMutedSoundRequestsUseSharedCooldown(t *testing.T) {
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
first, second := test_client(t, namespace), test_client(t, namespace)
|
|
played := make(chan struct{}, 4)
|
|
play := func(context.Context) { played <- struct{}{} }
|
|
first.Sound(play)
|
|
second.Sound(play)
|
|
select {
|
|
case <-played:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("muted notification cue was not delivered")
|
|
}
|
|
first.Close()
|
|
second.Close()
|
|
if len(played) != 0 {
|
|
t.Fatal("muted readers played multiple cues")
|
|
}
|
|
}
|
|
|
|
func TestCanceledClaimDoesNotConsumeCooldownAndNamespacesStaySeparate(t *testing.T) {
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
first := test_client(t, namespace)
|
|
second := test_client(t, namespace+".other")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if first.Claim_sound(ctx) {
|
|
t.Fatal("canceled cue was accepted")
|
|
}
|
|
first.Publish("Private history", time.Now())
|
|
if !first.Claim_sound(context.Background()) || !second.Claim_sound(context.Background()) {
|
|
t.Fatal("unrelated histories shared a cooldown")
|
|
}
|
|
if len(second.Snapshot().Entries) != 0 {
|
|
t.Fatal("history leaked to an unrelated group")
|
|
}
|
|
}
|
|
|
|
func TestBrokerRejectsInvalidFramesAndAdmitsCueAfterCooldown(t *testing.T) {
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
c := test_client(t, namespace)
|
|
if _, err := c.exchange(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, frame := range []string{
|
|
`{"Version":2}`,
|
|
`{"Version":1,"Seed":{"Entries":[{"ID":"bad","Text":"bad"}]},"Claim":true}`,
|
|
`{"Version":1,"Seed":{"Entries":[],"LastSound":"2999-01-01T00:00:00Z"},"Claim":true}`,
|
|
} {
|
|
connection, err := net.DialTimeout("unix", c.address, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.SetDeadline(time.Now().Add(time.Second))
|
|
fmt.Fprintln(connection, frame)
|
|
var result response
|
|
err = json.NewDecoder(connection).Decode(&result)
|
|
connection.Close()
|
|
if err == nil {
|
|
t.Fatal("invalid frame was accepted", frame)
|
|
}
|
|
}
|
|
c.brokerMu.Lock()
|
|
b := c.broker
|
|
c.brokerMu.Unlock()
|
|
b.mu.Lock()
|
|
b.state.LastSound = time.Now().Add(-SoundInterval)
|
|
b.mu.Unlock()
|
|
if !c.Claim_sound(context.Background()) {
|
|
t.Fatal("expired cooldown did not admit a cue")
|
|
}
|
|
}
|
|
|
|
type processRequest struct {
|
|
Publish string
|
|
Claim bool
|
|
Stop bool
|
|
}
|
|
|
|
type processResult struct {
|
|
State Snapshot
|
|
Allowed bool
|
|
}
|
|
|
|
func TestNotificationProcess(t *testing.T) {
|
|
namespace := os.Getenv("FENRIR_NOTIFICATION_PROCESS")
|
|
if namespace == "" {
|
|
return
|
|
}
|
|
c := New(context.Background(), os.Geteuid(), namespace)
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
decoder := json.NewDecoder(os.Stdin)
|
|
_ = encoder.Encode(processResult{})
|
|
for {
|
|
var message processRequest
|
|
if decoder.Decode(&message) != nil || message.Stop {
|
|
c.Close()
|
|
os.Exit(0)
|
|
}
|
|
if message.Publish != "" {
|
|
c.Publish(message.Publish, time.Now())
|
|
}
|
|
result, err := c.exchange(context.Background(), message.Claim)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = encoder.Encode(processResult{State: result.State, Allowed: result.Allowed})
|
|
}
|
|
}
|
|
|
|
type notificationProcess struct {
|
|
command *exec.Cmd
|
|
input *json.Encoder
|
|
output *json.Decoder
|
|
stopped bool
|
|
}
|
|
|
|
func start_process(t *testing.T, namespace string) *notificationProcess {
|
|
t.Helper()
|
|
command := exec.Command(os.Args[0], "-test.run=^TestNotificationProcess$")
|
|
command.Env = append(os.Environ(), "FENRIR_NOTIFICATION_PROCESS="+namespace)
|
|
command.Stderr = os.Stderr
|
|
input, err := command.StdinPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output, err := command.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := command.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := ¬ificationProcess{command: command, input: json.NewEncoder(input), output: json.NewDecoder(output)}
|
|
t.Cleanup(func() { p.stop(t) })
|
|
var ready processResult
|
|
if err := p.output.Decode(&ready); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (p *notificationProcess) send(t *testing.T, message processRequest) processResult {
|
|
t.Helper()
|
|
if err := p.input.Encode(message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var result processResult
|
|
if err := p.output.Decode(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p *notificationProcess) stop(t *testing.T) {
|
|
t.Helper()
|
|
if p.stopped {
|
|
return
|
|
}
|
|
p.stopped = true
|
|
_ = p.input.Encode(processRequest{Stop: true})
|
|
done := make(chan error, 1)
|
|
go func() { done <- p.command.Wait() }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
_ = p.command.Process.Kill()
|
|
<-done
|
|
t.Error("notification fixture did not exit")
|
|
}
|
|
}
|
|
|
|
func TestFourProcessesShareHistoryAndCooldownAcrossCoordinatorExit(t *testing.T) {
|
|
for _, crash := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("crash=%v", crash), func(t *testing.T) {
|
|
check_four_processes(t, crash)
|
|
})
|
|
}
|
|
}
|
|
|
|
func check_four_processes(t *testing.T, crash bool) {
|
|
t.Helper()
|
|
namespace := fmt.Sprintf(".%x", random_id())
|
|
processes := []*notificationProcess{start_process(t, namespace)}
|
|
want := processes[0].send(t, processRequest{Publish: "One targeted notification", Claim: true})
|
|
if len(want.State.Entries) != 1 || !want.Allowed {
|
|
t.Fatal(want)
|
|
}
|
|
for range 3 {
|
|
processes = append(processes, start_process(t, namespace))
|
|
}
|
|
for _, process := range processes[1:] {
|
|
got := process.send(t, processRequest{Claim: true})
|
|
if !reflect.DeepEqual(got.State.Entries, want.State.Entries) || got.Allowed {
|
|
t.Fatal("a replica lost history or claimed another cue", got)
|
|
}
|
|
}
|
|
if crash {
|
|
processes[0].stopped = true
|
|
if err := processes[0].command.Process.Kill(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = processes[0].command.Wait()
|
|
} else {
|
|
processes[0].stop(t)
|
|
}
|
|
for _, process := range processes[1:] {
|
|
got := process.send(t, processRequest{Claim: true})
|
|
if !reflect.DeepEqual(got.State.Entries, want.State.Entries) || got.Allowed {
|
|
t.Fatal("coordinator exit lost history or cooldown", got)
|
|
}
|
|
}
|
|
processes[1].send(t, processRequest{Publish: "Another targeted notification"})
|
|
for _, process := range processes[1:] {
|
|
got := process.send(t, processRequest{})
|
|
if len(got.State.Entries) != 2 || !strings.Contains(got.State.Entries[1].Text, "Another") {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
}
|