package emitter import ( "context" "errors" "os" "strings" "testing" ) // mockAtomicWriter is a test-only AtomicWriter that records calls so // the C-10 atomicity protocol (tmp + rename) can be asserted. type mockAtomicWriter struct { written []writeCall execed []execCall writeErr error writeWrote bool execErr error } type writeCall struct { peer string path string mode os.FileMode bytes []byte } type execCall struct { peer string cmd string } func (m *mockAtomicWriter) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) { m.written = append(m.written, writeCall{peer: peer, path: path, mode: mode, bytes: append([]byte(nil), content...)}) if m.writeErr != nil { return false, m.writeErr } return m.writeWrote, nil } func (m *mockAtomicWriter) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) { m.execed = append(m.execed, execCall{peer: peer, cmd: cmd}) if m.execErr != nil { return nil, m.execErr } return []byte("ok"), nil } func TestWriteTraefikDynamic_TmpThenRename(t *testing.T) { // Gate C-10: the Traefik dynamic-config write must be a tmp + // rename sequence so Traefik's fsnotify watcher never observes a // half-written file. mock := &mockAtomicWriter{writeWrote: true} path := "/etc/traefik/dynamic/orca-web.yaml" peer := "node-1:22" content := []byte("http:\n routers: {}\n") if err := WriteTraefikDynamic(context.Background(), mock, peer, path, content); err != nil { t.Fatalf("WriteTraefikDynamic: %v", err) } if len(mock.written) != 1 { t.Fatalf("WriteFileIdempotent calls = %d, want 1", len(mock.written)) } w := mock.written[0] if w.peer != peer { t.Errorf("write peer = %q, want %q", w.peer, peer) } // The tmp path is the target path + ".tmp". if w.path != path+".tmp" { t.Errorf("write path = %q, want %q (.tmp suffix is the C-10 atomicity protocol)", w.path, path+".tmp") } if string(w.bytes) != string(content) { t.Errorf("write content = %q, want %q", string(w.bytes), string(content)) } if w.mode != 0o644 { t.Errorf("write mode = %o, want 0644", w.mode) } if len(mock.execed) != 1 { t.Fatalf("Exec calls = %d, want 1 (the rename)", len(mock.execed)) } e := mock.execed[0] if e.peer != peer { t.Errorf("exec peer = %q, want %q", e.peer, peer) } // The rename command must `mv -f` the .tmp file to the final path. if !strings.Contains(e.cmd, "mv -f") { t.Errorf("exec cmd = %q, want it to contain 'mv -f' (atomic rename)", e.cmd) } if !strings.Contains(e.cmd, path+".tmp") { t.Errorf("exec cmd = %q, want it to contain the .tmp path as source", e.cmd) } if !strings.Contains(e.cmd, path) { t.Errorf("exec cmd = %q, want it to contain the final path as destination", e.cmd) } // Sanity: the source must come before the destination in the // mv command. srcIdx := strings.Index(e.cmd, path+".tmp") dstIdx := strings.Index(e.cmd, "'"+path+"'") if srcIdx < 0 || dstIdx < 0 || srcIdx > dstIdx { t.Errorf("exec cmd %q: source .tmp must come before destination %s", e.cmd, path) } } func TestWriteTraefikDynamic_WriteTmpError(t *testing.T) { mock := &mockAtomicWriter{writeErr: errors.New("disk full")} err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x")) if err == nil { t.Fatal("expected error from WriteFileIdempotent, got nil") } if !strings.Contains(err.Error(), "write tmp") { t.Errorf("error = %q, want 'write tmp'", err.Error()) } if !strings.Contains(err.Error(), "disk full") { t.Errorf("error = %q, want underlying 'disk full'", err.Error()) } if len(mock.execed) != 0 { t.Errorf("on tmp write failure, no rename should happen; execed = %v", mock.execed) } } func TestWriteTraefikDynamic_RenameError(t *testing.T) { mock := &mockAtomicWriter{writeWrote: true, execErr: errors.New("permission denied")} err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x")) if err == nil { t.Fatal("expected error from rename, got nil") } if !strings.Contains(err.Error(), "rename") { t.Errorf("error = %q, want 'rename'", err.Error()) } if !strings.Contains(err.Error(), "permission denied") { t.Errorf("error = %q, want underlying 'permission denied'", err.Error()) } } func TestWriteTraefikDynamic_NilWriter(t *testing.T) { err := WriteTraefikDynamic(context.Background(), nil, "p", "/x", []byte("x")) if err == nil { t.Fatal("expected error for nil writer") } if !strings.Contains(err.Error(), "nil") { t.Errorf("error = %q, want 'nil'", err.Error()) } } func TestWriteTraefikDynamic_EmptyPath(t *testing.T) { mock := &mockAtomicWriter{writeWrote: true} err := WriteTraefikDynamic(context.Background(), mock, "p", "", []byte("x")) if err == nil { t.Fatal("expected error for empty path") } if !strings.Contains(err.Error(), "path is empty") { t.Errorf("error = %q, want 'path is empty'", err.Error()) } } func TestWriteTraefikDynamic_SkipWhenContentMatches(t *testing.T) { // When the .tmp file already matches (writeWrote=false), the // protocol still proceeds with the rename — the idempotency // check is per-file, not per-protocol. The rename still happens // so the final path reflects the (unchanged) content. mock := &mockAtomicWriter{writeWrote: false} err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x")) if err != nil { t.Fatalf("WriteTraefikDynamic: %v", err) } if len(mock.execed) != 1 { t.Errorf("rename should still happen on idempotent skip; execed = %v", mock.execed) } } func TestShellQuoteLocal(t *testing.T) { cases := []struct { in, want string }{ {"/etc/traefik/dynamic/orca-web.yaml", "'/etc/traefik/dynamic/orca-web.yaml'"}, {"", "''"}, {"/path with space/x", "'/path with space/x'"}, {"a'b", "'a'\\''b'"}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { got := shellQuoteLocal(tc.in) if got != tc.want { t.Errorf("shellQuoteLocal(%q) = %q, want %q", tc.in, got, tc.want) } }) } }