ClipmanCli/cmd/clipman-cli/rules_test.go
Rama main · réplica de lectura del repositorio original.
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)
}
}