Ctrl K
ComunidadGuías…

ClipmanCli/cmd/clipman-cli/rules_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"sync"
	"testing"

	"github.com/OnjLouis/Clipman/ClipmanCli/internal/clipdb"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/config"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/identity"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/model"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/platform"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/rules"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/server"
	"github.com/OnjLouis/Clipman/ClipmanCli/internal/syncengine"
)

// The cross-client test vectors from sync-rules-spec.md section 2 keep the
// derived bucket ids in these tests aligned with the published ones.
const rulesCliToken = "example-token"
const rulesCliPassword = "example-password"
const rulesCliMachine = "Laptop"

func rulesCoreBucketID() string { return identity.DatabaseID(rulesCliToken, rulesCliPassword) }
func rulesBucketIDForTest() string {
	return identity.SyncRulesDatabaseID(rulesCliToken, rulesCliPassword)
}
func rulesChannelBucketID(key string) string {
	return identity.ChannelDatabaseID(rulesCliToken, rulesCliPassword, key)
}

type fakeBucket struct {
	blob     []byte
	revision string
}

// swapSpec is a one-shot content swap armed on a bucket: after `remaining`
// more GETs are served from its current content, it is replaced with blob
// under a fresh revision. This is how tests simulate another device
// concurrently editing a document between two reads of it.
type swapSpec struct {
	remaining int
	blob      []byte
}

// fakeMultiServer is a multi-bucket stand-in for Clipman Server: GET/PUT
// /api/v1/database/{id} for any number of database ids, enforcing the
// If-Match/If-None-Match preconditions the engine and the rules commands
// both rely on.
type fakeMultiServer struct {
	lock     sync.Mutex
	buckets  map[string]*fakeBucket
	requests []string
	created  map[string]bool
	broken   map[string]bool
	swaps    map[string]*swapSpec
	sequence int
}

func newFakeMultiServer() *fakeMultiServer {
	return &fakeMultiServer{
		buckets: map[string]*fakeBucket{},
		created: map[string]bool{},
		broken:  map[string]bool{},
		swaps:   map[string]*swapSpec{},
	}
}

// swapAfterGets arms a one-shot content swap on bucket id: the `count`-th GET
// still returns whatever is currently stored, and the swap happens right
// after, so the very next GET (and every one after) sees blob instead.
func (s *fakeMultiServer) swapAfterGets(id string, count int, blob []byte) {
	s.lock.Lock()
	defer s.lock.Unlock()
	s.swaps[id] = &swapSpec{remaining: count, blob: blob}
}

func (s *fakeMultiServer) breakBucket(id string) {
	s.lock.Lock()
	defer s.lock.Unlock()
	s.broken[id] = true
}

func (s *fakeMultiServer) unbreakBucket(id string) {
	s.lock.Lock()
	defer s.lock.Unlock()
	delete(s.broken, id)
}

func (s *fakeMultiServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	id, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/api/v1/database/"))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	s.lock.Lock()
	defer s.lock.Unlock()
	s.requests = append(s.requests, r.Method+" "+id)
	switch r.Method {
	case http.MethodGet:
		bucket := s.buckets[id]
		if bucket == nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("X-Clipman-Revision", bucket.revision)
		_, _ = w.Write(bucket.blob)
		if spec, ok := s.swaps[id]; ok {
			spec.remaining--
			if spec.remaining <= 0 {
				s.sequence++
				s.buckets[id] = &fakeBucket{blob: spec.blob, revision: fmt.Sprintf("revision-%d", s.sequence)}
				delete(s.swaps, id)
			}
		}
	case http.MethodPut:
		data, readErr := io.ReadAll(r.Body)
		if readErr != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		if s.broken[id] {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		existing := s.buckets[id]
		if r.Header.Get("If-None-Match") == "*" {
			if existing != nil {
				w.WriteHeader(http.StatusPreconditionFailed)
				return
			}
			s.created[id] = true
		} else if existing != nil {
			if strings.Trim(r.Header.Get("If-Match"), "\"") != existing.revision {
				w.WriteHeader(http.StatusPreconditionFailed)
				return
			}
		}
		s.sequence++
		revision := fmt.Sprintf("revision-%d", s.sequence)
		s.buckets[id] = &fakeBucket{blob: data, revision: revision}
		w.Header().Set("X-Clipman-Revision", revision)
		w.WriteHeader(http.StatusOK)
	default:
		w.WriteHeader(http.StatusMethodNotAllowed)
	}
}

func (s *fakeMultiServer) storeBlob(id string, blob []byte) {
	s.lock.Lock()
	defer s.lock.Unlock()
	s.sequence++
	s.buckets[id] = &fakeBucket{blob: blob, revision: fmt.Sprintf("revision-%d", s.sequence)}
}

func (s *fakeMultiServer) storeDatabase(t *testing.T, id string, database model.Database) {
	t.Helper()
	blob, err := clipdb.Encode(database, rulesCliPassword, nil)
	if err != nil {
		t.Fatal(err)
	}
	s.storeBlob(id, blob)
}

func (s *fakeMultiServer) storeRules(t *testing.T, doc *rules.Document) {
	t.Helper()
	payload, err := rules.Serialize(doc)
	if err != nil {
		t.Fatal(err)
	}
	blob, err := clipdb.EncodeRaw(payload, rulesCliPassword, nil)
	if err != nil {
		t.Fatal(err)
	}
	s.storeBlob(rulesBucketIDForTest(), blob)
}

func (s *fakeMultiServer) blob(id string) []byte {
	s.lock.Lock()
	defer s.lock.Unlock()
	bucket := s.buckets[id]
	if bucket == nil {
		return nil
	}
	return append([]byte(nil), bucket.blob...)
}

func (s *fakeMultiServer) database(t *testing.T, id string) model.Database {
	t.Helper()
	blob := s.blob(id)
	if blob == nil {
		t.Fatalf("bucket %q does not exist", id)
	}
	database, err := clipdb.Decode(blob, rulesCliPassword, clipdb.DefaultLimits())
	if err != nil {
		t.Fatal(err)
	}
	return database
}

func (s *fakeMultiServer) rulesDoc(t *testing.T) *rules.Document {
	t.Helper()
	blob := s.blob(rulesBucketIDForTest())
	if blob == nil {
		t.Fatal("rules bucket does not exist")
	}
	payload, _, err := clipdb.DecodeRaw(blob, rulesCliPassword)
	if err != nil {
		t.Fatal(err)
	}
	doc, err := rules.Parse(payload)
	if err != nil {
		t.Fatal(err)
	}
	return doc
}

func (s *fakeMultiServer) count(method, id string) int {
	s.lock.Lock()
	defer s.lock.Unlock()
	total := 0
	for _, entry := range s.requests {
		if entry == method+" "+id {
			total++
		}
	}
	return total
}

func (s *fakeMultiServer) exists(id string) bool {
	s.lock.Lock()
	defer s.lock.Unlock()
	return s.buckets[id] != nil
}

// newRulesTestContext builds an appContext wired to a fresh fakeMultiServer,
// with its configPath pointed at a temp directory so the rules cache and
// pending-writes store land somewhere real but disposable.
func newRulesTestContext(t *testing.T) (*fakeMultiServer, *appContext) {
	t.Helper()
	fake := newFakeMultiServer()
	testServer := httptest.NewServer(fake)
	t.Cleanup(testServer.Close)
	client, err := server.New(testServer.URL, rulesCliToken, rulesCoreBucketID(), "test")
	if err != nil {
		t.Fatal(err)
	}
	client.HTTP = testServer.Client()

	dir := t.TempDir()
	cfg := config.Default()
	cfg.Machine = rulesCliMachine

	engine := &syncengine.Engine{
		Client:   client,
		Password: rulesCliPassword,
		Limits:   clipdb.DefaultLimits(),
		Retries:  2,
		Token:    rulesCliToken,
	}
	ctx := &appContext{
		globals:    globals{quiet: true},
		configPath: filepath.Join(dir, "config.toml"),
		config:     cfg,
		token:      rulesCliToken,
		password:   rulesCliPassword,
		databaseID: rulesCoreBucketID(),
		client:     client,
		engine:     engine,
	}
	return fake, ctx
}

func testHistoryEntry(id, text, group string, stamp int64) model.Entry {
	return model.Entry{
		ID: id, Text: text, Group: group,
		CreatedUnixMs: stamp, LastUsedUnixMs: stamp, ModifiedUnixMs: stamp,
		Extra: map[string]json.RawMessage{},
	}
}

// imageEntryForTest carries the rich text the RichTextImages route matches.
func imageEntryForTest(id, text string, stamp int64) model.Entry {
	entry := testHistoryEntry(id, text, "", stamp)
	entry.Extra["RichText"] = json.RawMessage(`{"HtmlFragment":"<img src=\"data:image/png;base64,AAAA\">"}`)
	return entry
}

func historyDatabaseWith(entries ...model.Entry) model.Database {
	database := model.NewDatabase(1000)
	database.Entries = append(database.Entries, entries...)
	return database
}

func rulesHasEntry(database model.Database, id string) bool {
	for _, entry := range database.Entries {
		if entry.ID == id {
			return true
		}
	}
	return false
}

func TestRulesShowDisabled(t *testing.T) {
	_, ctx := newRulesTestContext(t)
	output := captureStdout(t, func() {
		if err := runRules(ctx, []string{"show"}); err != nil {
			t.Fatalf("rules show: %v", err)
		}
	})
	if !strings.Contains(output, "Sync rules are not enabled.") {
		t.Fatalf("output = %q", output)
	}
}

func TestRulesShowDocument(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: "Desktop", Channels: []string{"*"}}},
	})

	output := captureStdout(t, func() {
		if err := runRules(ctx, []string{"show"}); err != nil {
			t.Fatalf("rules show: %v", err)
		}
	})
	if !strings.Contains(output, "Work") || !strings.Contains(output, "Desktop") {
		t.Fatalf("human output missing channel/device summary: %q", output)
	}

	ctx.globals.json = true
	jsonOutput := captureStdout(t, func() {
		if err := runRules(ctx, []string{"show"}); err != nil {
			t.Fatalf("rules show --json: %v", err)
		}
	})
	var doc rules.Document
	if err := json.Unmarshal([]byte(jsonOutput), &doc); err != nil {
		t.Fatalf("json output = %q: %v", jsonOutput, err)
	}
	if doc.Clipman != "sync-rules" || len(doc.Channels) != 1 || doc.Channels[0].Name != "Work" {
		t.Fatalf("decoded doc = %#v", doc)
	}
}

func TestRulesEnableCreatesDocumentWithSelfDevice(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	if err := runRules(ctx, []string{"enable"}); err != nil {
		t.Fatalf("rules enable: %v", err)
	}
	doc := fake.rulesDoc(t)
	if !doc.Enabled || doc.Version != 1 {
		t.Fatalf("doc = %#v", doc)
	}
	if len(doc.Channels) != 0 {
		t.Fatalf("channels = %#v, want none", doc.Channels)
	}
	if len(doc.Devices) != 1 || doc.Devices[0].Name != rulesCliMachine || len(doc.Devices[0].Channels) != 1 || doc.Devices[0].Channels[0] != "*" {
		t.Fatalf("devices = %#v, want this device with wildcard subscription", doc.Devices)
	}
	if !fake.created[rulesBucketIDForTest()] {
		t.Fatal("rules bucket was not created with If-None-Match")
	}
}

func TestRulesChannelAddValidatesAndUploads(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	if err := runRules(ctx, []string{"enable"}); err != nil {
		t.Fatalf("rules enable: %v", err)
	}

	if err := runRules(ctx, []string{"channel", "add"}); err == nil {
		t.Fatal("channel add with no name should fail")
	}
	if err := runRules(ctx, []string{"channel", "add", "Work"}); err == nil {
		t.Fatal("channel add with no condition should fail")
	}
	if err := runRules(ctx, []string{"channel", "add", "core", "--group", "Work"}); err == nil {
		t.Fatal("channel add with a reserved name should fail")
	}

	if err := runRules(ctx, []string{"channel", "add", "Work", "--group", "Work", "--group", "Standup"}); err != nil {
		t.Fatalf("channel add: %v", err)
	}
	doc := fake.rulesDoc(t)
	if len(doc.Channels) != 1 || doc.Channels[0].Name != "Work" {
		t.Fatalf("channels = %#v", doc.Channels)
	}
	if len(doc.Channels[0].Route.Groups) != 2 {
		t.Fatalf("route groups = %#v", doc.Channels[0].Route.Groups)
	}
}

func TestRulesChannelRemoveReroutesEntries(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{"*"}}},
	})
	fake.storeDatabase(t, rulesChannelBucketID("work"), historyDatabaseWith(testHistoryEntry("w1", "hello", "Work", 1000)))

	if err := runRules(ctx, []string{"channel", "remove", "Work"}); err != nil {
		t.Fatalf("channel remove: %v", err)
	}

	core := fake.database(t, rulesCoreBucketID())
	if !rulesHasEntry(core, "w1") {
		t.Fatalf("core entries = %v, want the rerouted entry", core.Entries)
	}
	work := fake.database(t, rulesChannelBucketID("work"))
	if len(work.Entries) != 0 {
		t.Fatalf("work channel entries = %v, want none left", work.Entries)
	}
	doc := fake.rulesDoc(t)
	for _, channel := range doc.Channels {
		if rules.ChannelKey(channel.Name) == "work" {
			t.Fatal("the work channel is still listed in the rules document")
		}
	}
}

func TestRulesChannelRemoveRefusesWhenNotSubscribed(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{}}},
	})
	fake.storeDatabase(t, rulesChannelBucketID("work"), historyDatabaseWith(testHistoryEntry("w1", "hello", "Work", 1000)))

	err := runRules(ctx, []string{"channel", "remove", "Work"})
	if err == nil || !strings.Contains(err.Error(), "not subscribed") {
		t.Fatalf("error = %v, want a not-subscribed refusal", err)
	}
}

func TestRulesDeviceSetSubscriptions(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
	})

	if err := runRules(ctx, []string{"device", "set", "Phone", "--channels", "work"}); err != nil {
		t.Fatalf("device set: %v", err)
	}
	doc := fake.rulesDoc(t)
	if len(doc.Devices) != 1 || doc.Devices[0].Name != "Phone" || len(doc.Devices[0].Channels) != 1 || doc.Devices[0].Channels[0] != "work" {
		t.Fatalf("devices = %#v", doc.Devices)
	}

	if err := runRules(ctx, []string{"device", "set", "Phone", "--channels", "*"}); err != nil {
		t.Fatalf("device set wildcard: %v", err)
	}
	doc = fake.rulesDoc(t)
	if len(doc.Devices) != 1 || doc.Devices[0].Channels[0] != "*" {
		t.Fatalf("devices after wildcard update = %#v", doc.Devices)
	}

	if err := runRules(ctx, []string{"device", "set", "Phone", "--channels", "nonexistent"}); err == nil {
		t.Fatal("device set referencing an unknown channel should fail")
	}
}

func TestListUsesSubscribedViewOnly(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeDatabase(t, rulesCoreBucketID(), historyDatabaseWith(testHistoryEntry("a", "alpha", "", 1000)))
	fake.storeDatabase(t, rulesChannelBucketID("work"), historyDatabaseWith(testHistoryEntry("b", "beta", "Work", 1000)))
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{}}},
	})

	ctx.globals.json = true
	output := captureStdout(t, func() {
		if err := runList(ctx, []string{}); err != nil {
			t.Fatalf("list: %v", err)
		}
	})
	if !strings.Contains(output, "alpha") {
		t.Fatalf("list output missing the subscribed core entry: %q", output)
	}
	if strings.Contains(output, "beta") {
		t.Fatalf("list output includes an unsubscribed channel's entry: %q", output)
	}
	if fake.count("GET", rulesChannelBucketID("work")) != 0 {
		t.Fatal("the unsubscribed work channel bucket was fetched")
	}
}

func TestSyncSelfRegistersMissingDevice(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
	})

	if err := runSync(ctx, []string{}); err != nil {
		t.Fatalf("sync: %v", err)
	}
	doc := fake.rulesDoc(t)
	found := false
	for _, device := range doc.Devices {
		if device.Name == rulesCliMachine {
			found = true
			if len(device.Channels) != 1 || device.Channels[0] != "*" {
				t.Fatalf("self-registered channels = %#v, want [\"*\"]", device.Channels)
			}
		}
	}
	if !found {
		t.Fatalf("devices = %#v, want this device registered", doc.Devices)
	}
}

func TestSyncSelfRegistrationFailureDoesNotFailSync(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
	})
	fake.breakBucket(rulesBucketIDForTest())

	if err := runSync(ctx, []string{}); err != nil {
		t.Fatalf("sync must succeed even when self-registration fails: %v", err)
	}
}

func TestWriteThroughPendingRetry(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{}}},
	})
	fake.breakBucket(rulesChannelBucketID("work"))

	if err := runPut(ctx, []string{"--text", "hello", "--group", "Work"}); err != nil {
		t.Fatalf("put must succeed locally even when write-through fails: %v", err)
	}
	pendingPath := pendingWritesPath(ctx.configPath)
	if _, err := os.Stat(pendingPath); err != nil {
		t.Fatalf("pending-writes.json was not created: %v", err)
	}
	pending := loadPendingWrites(ctx.configPath)
	if len(pending["work"]) != 1 {
		t.Fatalf("pending = %#v, want one entry parked for work", pending)
	}

	fake.unbreakBucket(rulesChannelBucketID("work"))
	if err := runSync(ctx, []string{}); err != nil {
		t.Fatalf("sync: %v", err)
	}
	if _, err := os.Stat(pendingPath); !os.IsNotExist(err) {
		t.Fatalf("pending-writes.json should be cleared after a successful retry, stat err = %v", err)
	}
	work := fake.database(t, rulesChannelBucketID("work"))
	if len(work.Entries) != 1 || work.Entries[0].Text != "hello" {
		t.Fatalf("work channel entries = %#v, want the retried entry delivered", work.Entries)
	}
}

func TestRulesCommandsRefuseReadOnlyFutureDocument(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 2, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Weird", Route: rules.Route{Kind: "SomeFutureKind"}}},
	})

	for _, args := range [][]string{
		{"enable"},
		{"disable"},
		{"channel", "add", "Work", "--group", "Work"},
		{"channel", "remove", "Weird"},
		{"device", "set", "Phone", "--channels", "*"},
	} {
		if err := runRules(ctx, args); err == nil {
			t.Fatalf("rules %v: expected a refusal for a read-only future document", args)
		}
	}
}

// --- fixes from code review ------------------------------------------------

// CRITICAL 1: absorbWriteThrough must not report success when a subscribed
// channel upload also failed (WriteThroughError.Committed == false) - it must
// still park the pending entries (they are real), but return the error so the
// caller reports a nonzero exit.
func TestAbsorbWriteThroughReportsFailureWhenChannelUploadAlsoFails(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{
			{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}},
			{Name: "Images", Route: rules.Route{Kind: "RichTextImages"}},
		},
		// Subscribed to "work" (a normal uploaded channel) but not "images"
		// (which only ever reaches the server through write-through).
		Devices: []rules.Device{{Name: rulesCliMachine, Channels: []string{"work"}}},
	})
	fake.breakBucket(rulesChannelBucketID("work"))
	fake.breakBucket(rulesChannelBucketID("images"))

	_, mutateErr := ctx.engine.MutateView(context.Background(), rulesCliMachine, func(database *model.Database) error {
		database.Entries = append(database.Entries, testHistoryEntry("w", "work entry", "Work", 1000))
		database.Entries = append(database.Entries, imageEntryForTest("i", "image entry", 1000))
		return nil
	})
	if mutateErr == nil {
		t.Fatal("expected an error: a subscribed channel upload failed alongside a write-through failure")
	}

	if err := absorbWriteThrough(ctx, mutateErr); err == nil {
		t.Fatal("absorbWriteThrough must report failure (nonzero exit) when Committed is false")
	}
	pending := loadPendingWrites(ctx.configPath)
	if len(pending["images"]) != 1 {
		t.Fatalf("pending = %#v, want the undelivered image entry parked despite the reported failure", pending)
	}
}

// CRITICAL 2: rules channel remove must never touch the rules document when
// step 1 (relocating the channel's entries) did not actually succeed.
func TestRulesChannelRemoveAbortsWhenChannelUploadFails(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{"*"}}},
	})
	fake.storeDatabase(t, rulesChannelBucketID("work"), historyDatabaseWith(testHistoryEntry("w1", "hello", "Work", 1000)))
	fake.breakBucket(rulesChannelBucketID("work"))

	before := fake.rulesDoc(t)

	if err := runRules(ctx, []string{"channel", "remove", "Work"}); err == nil {
		t.Fatal("channel remove must fail when the channel's own bucket upload fails")
	}

	after := fake.rulesDoc(t)
	if !reflect.DeepEqual(before, after) {
		t.Fatalf("rules document changed despite the failed relocation:\n before %#v\n after  %#v", before, after)
	}
	if fake.count("PUT", rulesBucketIDForTest()) != 0 {
		t.Fatal("the rules bucket must not be written when step 1 fails")
	}
	work := fake.database(t, rulesChannelBucketID("work"))
	if !rulesHasEntry(work, "w1") {
		t.Fatal("the entry must still be in the channel: it was never actually relocated")
	}
}

// IMPORTANT 3: a genuinely newer remote rules edit, arriving between the
// initial read and the mutation, must abort the command rather than silently
// skip the reroute while still deleting the channel from the document.
func TestRulesChannelRemoveAbortsOnConcurrentRulesEdit(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	original := &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{"*"}}},
	}
	fake.storeRules(t, original)
	fake.storeDatabase(t, rulesChannelBucketID("work"), historyDatabaseWith(testHistoryEntry("w1", "hello", "Work", 1000)))

	// A concurrent edit from another device, timestamped far enough in the
	// future to always win the last-writer-wins merge against this command's
	// transitional document.
	concurrent := &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 99999999999999, UpdatedBy: "Other-Device",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{"*"}}},
	}
	payload, err := rules.Serialize(concurrent)
	if err != nil {
		t.Fatal(err)
	}
	concurrentBlob, err := clipdb.EncodeRaw(payload, rulesCliPassword, nil)
	if err != nil {
		t.Fatal(err)
	}
	// The 1st GET (this command's initial ReadView) still sees `original`;
	// every GET after that (starting with MutateView's own internal read)
	// sees the concurrent edit instead.
	fake.swapAfterGets(rulesBucketIDForTest(), 1, concurrentBlob)

	err = runRules(ctx, []string{"channel", "remove", "Work"})
	if err == nil || !strings.Contains(err.Error(), "changed on another device") {
		t.Fatalf("error = %v, want a concurrent-edit refusal", err)
	}

	work := fake.database(t, rulesChannelBucketID("work"))
	if !rulesHasEntry(work, "w1") {
		t.Fatal("the entry must not be relocated out of the channel under the wrong document")
	}
	after := fake.rulesDoc(t)
	if after.UpdatedUnixMs != concurrent.UpdatedUnixMs || after.UpdatedBy != concurrent.UpdatedBy {
		t.Fatalf("rules document changed unexpectedly: %#v", after)
	}
}

// IMPORTANT 4: a read-only (future-version) cached document must never arm
// the engine's 404 re-upload fallback, since this client is not allowed to
// rewrite a document it only partly understands.
func TestReadOnlyRulesCacheNeverArmsReupload(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	future := &rules.Document{
		Clipman: "sync-rules", Version: 2, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Weird", Route: rules.Route{Kind: "SomeFutureKind"}}},
	}
	data, err := rules.Serialize(future)
	if err != nil {
		t.Fatal(err)
	}
	if err := platform.SavePrivate(rulesCachePath(ctx.configPath), data); err != nil {
		t.Fatal(err)
	}
	// Mirrors what loadContext does at process startup.
	ctx.engine.CachedRules = initialCachedRules(ctx.configPath)
	if ctx.engine.CachedRules != nil {
		t.Fatal("a read-only cached document must not arm the engine's CachedRules")
	}

	// The rules bucket 404s (nothing stored server-side). A command that
	// reads rules must not restore the cached document by PUTting it back.
	captureStdout(t, func() {
		if err := runRules(ctx, []string{"show"}); err != nil {
			t.Fatalf("rules show: %v", err)
		}
	})
	if fake.count("PUT", rulesBucketIDForTest()) != 0 {
		t.Fatal("a read-only cached document must never be re-uploaded on a 404")
	}
}

// MINOR 5: putRulesDocument must reuse the salt from the rules bucket's own
// just-downloaded blob instead of fetching core, whenever that blob exists.
func TestPutRulesDocumentReusesRulesBlobSaltWithoutFetchingCore(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
	})
	// No core bucket exists at all; a fallback fetch for salt would be a GET
	// against a bucket that has never been created.
	if err := runRules(ctx, []string{"enable"}); err != nil {
		t.Fatalf("rules enable: %v", err)
	}
	if fake.count("GET", rulesCoreBucketID()) != 0 {
		t.Fatal("putRulesDocument fetched core for salt despite already holding the rules blob's own salt")
	}
}

// MINOR 6: a pending write-through entry must be re-routed against the
// CURRENT rules document at retry time, not resent to the channel key it was
// originally parked under - that channel may have been removed since.
func TestPendingRetryRegroupsToCurrentRulesAfterChannelRemoval(t *testing.T) {
	fake, ctx := newRulesTestContext(t)
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 1000, UpdatedBy: "Desktop",
		Channels: []rules.Channel{{Name: "Work", Route: rules.Route{Groups: []string{"Work"}}}},
		Devices:  []rules.Device{{Name: rulesCliMachine, Channels: []string{}}},
	})
	fake.breakBucket(rulesChannelBucketID("work"))

	if err := runPut(ctx, []string{"--text", "hello", "--group", "Work"}); err != nil {
		t.Fatalf("put must succeed locally even when write-through fails: %v", err)
	}
	pending := loadPendingWrites(ctx.configPath)
	if len(pending["work"]) != 1 {
		t.Fatalf("pending = %#v, want one entry parked for work", pending)
	}

	// Another device removes the "work" channel entirely while the entry is
	// still parked for it.
	fake.storeRules(t, &rules.Document{
		Clipman: "sync-rules", Version: 1, Enabled: true,
		UpdatedUnixMs: 2000, UpdatedBy: "Desktop",
		Devices: []rules.Device{{Name: rulesCliMachine, Channels: []string{}}},
	})

	if err := runSync(ctx, []string{}); err != nil {
		t.Fatalf("sync: %v", err)
	}
	if fake.exists(rulesChannelBucketID("work")) {
		t.Fatal("the removed channel's bucket must not be recreated by the pending retry")
	}
	if pending := loadPendingWrites(ctx.configPath); len(pending) != 0 {
		t.Fatalf("pending writes were not cleared: %#v", pending)
	}
	core := fake.database(t, rulesCoreBucketID())
	if len(core.Entries) != 1 || core.Entries[0].Text != "hello" {
		t.Fatalf("core entries = %#v, want the re-routed pending entry delivered to core", core.Entries)
	}
}