From cb802c309950d1299d8c00aef8792748f2e3d92f Mon Sep 17 00:00:00 2001 From: Cabbie Team Date: Fri, 18 Sep 2026 17:54:11 -0700 Subject: [PATCH] Internal Change PiperOrigin-RevId: 984161746 --- README.md | 27 ++++- cabbie.go | 11 +++ enforcement/enforcement.go | 46 +++++++++ enforcement/enforcement_test.go | 94 ++++++++++++++++++ enforcement/testdata/unhide.json | 10 ++ hide.go | 129 ++++++++++++++++++++---- hide_test.go | 163 +++++++++++++++++++++++++++++++ 7 files changed, 459 insertions(+), 21 deletions(-) create mode 100644 enforcement/testdata/unhide.json create mode 100644 hide_test.go diff --git a/README.md b/README.md index 1d17bbf..a6df062 100644 --- a/README.md +++ b/README.md @@ -105,16 +105,23 @@ Retrieves the recorded history of installed updates. ### Hide -Hides or unhides an update from installation. +Hides or unhides an update from installation. Updates can be selected by KB +article ID (`--kbs`), by Update ID (`--update-ids`), or both. Hide a KB: `cabbie hide --kbs="1234513"` +Hide an update by its Update ID: + +`cabbie hide --update-ids="1234ccd5-1234-456f-78gh-ij2911553881"` + Make an update available for install: `cabbie hide --unhide --kbs="1234513"` +`cabbie hide --unhide --update-ids="1234ccd5-1234-456f-78gh-ij2911553881"` + ### Reboot Manage the pending reboot state of the machine. @@ -199,6 +206,18 @@ string under the `hidden` key or use the client-side Windows Update Agent (WUA) > represents a server-side package container GUID that does not match the > client-side `UpdateID`. +Removing an update from the `hidden` or `hidden-UpdateID` keys does not make it +visible again, because Cabbie has no record of why an update was hidden and will +not undo hides performed by an administrator or another tool. To make a +previously hidden update visible again, place the KB article string under the +`unhide` key or the Update ID under the `unhide-UpdateID` key. Unhidden updates +become eligible for installation during the next update cycle. + +Enforcements are aggregated across every json file in the enforcement directory. +If the same update is listed as both hidden and unhidden, hiding wins and the +conflict is logged. This holds even when the two entries use different +identifiers, such as hiding an update by KB ID and unhiding it by Update ID. + Example: ```json @@ -211,6 +230,12 @@ Example: ], "hidden-UpdateID": [ "1234ccd5-1234-456f-78gh-ij2911553881" + ], + "unhide": [ + "789012" + ], + "unhide-UpdateID": [ + "5678ccd5-1234-456f-78gh-ij2911553882" ] } ``` diff --git a/cabbie.go b/cabbie.go index 56c3416..7b598f6 100644 --- a/cabbie.go +++ b/cabbie.go @@ -332,6 +332,17 @@ func enforce() error { deck.ErrorA(failures).With(eventID(cablib.EvtErrInstallFailure)).Go() } } + if len(updates.Conflicts) > 0 { + deck.ErrorfA("Ignoring unhide enforcement for updates that are also explicitly hidden: %v", updates.Conflicts).With(eventID(cablib.EvtErrEnforcement)).Go() + } + if len(updates.Unhide) > 0 || len(updates.UnhideUpdateID) > 0 { + want := updateSet{kbs: NewKBSetFromSlice(updates.Unhide), uuids: updates.UnhideUpdateID} + hidden := updateSet{kbs: NewKBSetFromSlice(updates.Hidden), uuids: updates.HiddenUpdateID} + if err := unhide(want, hidden); err != nil { + failures = fmt.Errorf("error unhiding updates: %v", err) + deck.ErrorA(failures).With(eventID(cablib.EvtErrUnhide)).Go() + } + } if len(updates.Hidden) > 0 { if err := hide(NewKBSetFromSlice(updates.Hidden)); err != nil { failures = fmt.Errorf("error hiding updates: %v", err) diff --git a/enforcement/enforcement.go b/enforcement/enforcement.go index 7292069..6577280 100644 --- a/enforcement/enforcement.go +++ b/enforcement/enforcement.go @@ -24,6 +24,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/google/cabbie/cablib" @@ -45,6 +46,9 @@ type Enforcements struct { ExcludedDrivers []DriverExclude `json:"excluded-drivers"` Hidden []string `json:"hidden"` HiddenUpdateID []string `json:"hidden-UpdateID"` + Unhide []string `json:"unhide"` + UnhideUpdateID []string `json:"unhide-UpdateID"` + Conflicts []string `json:"-"` } // DriverExclude specifies criteria to exclude certain driver updates. @@ -95,8 +99,11 @@ func Get() (Enforcements, error) { ret.Hidden = append(ret.Hidden, e.Hidden...) ret.ExcludedDrivers = append(ret.ExcludedDrivers, e.ExcludedDrivers...) ret.HiddenUpdateID = append(ret.HiddenUpdateID, e.HiddenUpdateID...) + ret.Unhide = append(ret.Unhide, e.Unhide...) + ret.UnhideUpdateID = append(ret.UnhideUpdateID, e.UnhideUpdateID...) } ret.dedupe() + ret.reconcile() return ret, nil } @@ -131,9 +138,48 @@ func (e *Enforcements) dedupe() { e.Required = uniqueStrings(e.Required) e.Hidden = uniqueStrings(e.Hidden) e.HiddenUpdateID = uniqueStrings(e.HiddenUpdateID) + e.Unhide = uniqueStrings(e.Unhide) + e.UnhideUpdateID = uniqueStrings(e.UnhideUpdateID) e.ExcludedDrivers = uniqueDriverExclude(e.ExcludedDrivers) } +func normalizeKB(kb string) string { + return strings.ReplaceAll(strings.ToLower(kb), "kb", "") +} + +// reconcile resolves entries that are requested to be both hidden and +// unhidden. If in both, hiding wins and we log it. +func (e *Enforcements) reconcile() { + hidden := make(map[string]bool, len(e.Hidden)) + for _, kb := range e.Hidden { + hidden[normalizeKB(kb)] = true + } + hiddenID := make(map[string]bool, len(e.HiddenUpdateID)) + for _, id := range e.HiddenUpdateID { + hiddenID[strings.ToLower(id)] = true + } + + unhide := make([]string, 0, len(e.Unhide)) + for _, kb := range e.Unhide { + if hidden[normalizeKB(kb)] { + e.Conflicts = append(e.Conflicts, kb) + continue + } + unhide = append(unhide, kb) + } + e.Unhide = unhide + + unhideID := make([]string, 0, len(e.UnhideUpdateID)) + for _, id := range e.UnhideUpdateID { + if hiddenID[strings.ToLower(id)] { + e.Conflicts = append(e.Conflicts, id) + continue + } + unhideID = append(unhideID, id) + } + e.UnhideUpdateID = unhideID +} + // Watcher runs a filesystem watcher for required updates. This is meant to install required updates as soon as they are configured. // All configured required updates are read on a schedule (see cabbie.go t.Enforcement ticker usage) to ensure required // updates are installed even if a filesystem event is missed. diff --git a/enforcement/enforcement_test.go b/enforcement/enforcement_test.go index 1e7a604..24d2664 100644 --- a/enforcement/enforcement_test.go +++ b/enforcement/enforcement_test.go @@ -53,6 +53,16 @@ func TestDedupe(t *testing.T) { Enforcements{Hidden: []string{"4018073", "67891011", "4018073", "4018073"}}, Enforcements{Hidden: []string{"4018073", "67891011"}}, }, + { + "with dup unhide", + Enforcements{Unhide: []string{"4018073", "67891011", "4018073", "4018073"}}, + Enforcements{Unhide: []string{"4018073", "67891011"}}, + }, + { + "with dup unhide update ids", + Enforcements{UnhideUpdateID: []string{"8870bdb3", "245bd515", "8870bdb3"}}, + Enforcements{UnhideUpdateID: []string{"8870bdb3", "245bd515"}}, + }, { "with dup excluded drivers", Enforcements{ExcludedDrivers: []DriverExclude{ @@ -82,6 +92,83 @@ func TestDedupe(t *testing.T) { } } +func TestReconcile(t *testing.T) { + tests := []struct { + desc string + in Enforcements + want Enforcements + }{ + { + "no conflicts", + Enforcements{ + Hidden: []string{"4018073"}, + HiddenUpdateID: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2"}, + Unhide: []string{"67891011"}, + UnhideUpdateID: []string{"245bd515-7b95-496e-acac-344881833263"}, + }, + Enforcements{ + Hidden: []string{"4018073"}, + HiddenUpdateID: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2"}, + Unhide: []string{"67891011"}, + UnhideUpdateID: []string{"245bd515-7b95-496e-acac-344881833263"}, + }, + }, + { + "hidden wins over unhide", + Enforcements{ + Hidden: []string{"4018073"}, + Unhide: []string{"4018073", "67891011"}, + }, + Enforcements{ + Hidden: []string{"4018073"}, + Unhide: []string{"67891011"}, + Conflicts: []string{"4018073"}, + }, + }, + { + "kb prefix is normalized", + Enforcements{ + Hidden: []string{"KB4018073"}, + Unhide: []string{"4018073"}, + }, + Enforcements{ + Hidden: []string{"KB4018073"}, + Conflicts: []string{"4018073"}, + }, + }, + { + "hidden wins over unhide update id", + Enforcements{ + HiddenUpdateID: []string{"8870BDB3-95F9-43D9-9BA4-1F0E7CF13DB2"}, + UnhideUpdateID: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2"}, + }, + Enforcements{ + HiddenUpdateID: []string{"8870BDB3-95F9-43D9-9BA4-1F0E7CF13DB2"}, + Conflicts: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2"}, + }, + }, + { + "unhide by update id is unaffected by a hidden kb", + Enforcements{ + Hidden: []string{"4018073"}, + UnhideUpdateID: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2"}, + }, + Enforcements{ + Hidden: []string{"4018073"}, + UnhideUpdateID: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + tt.in.reconcile() + if diff := cmp.Diff(tt.want, tt.in, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("reconcile(%s) returned unexpected diff (-want +got):\n%s", tt.desc, diff) + } + }) + } +} + func TestEnforcements(t *testing.T) { tests := []struct { in string @@ -96,6 +183,13 @@ func TestEnforcements(t *testing.T) { Enforcements{Hidden: []string{"4018073", "67891011"}}, nil, }, + {"unhide.json", + Enforcements{ + Unhide: []string{"4018073", "67891011"}, + UnhideUpdateID: []string{"8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2", "245bd515-7b95-496e-acac-344881833263"}, + }, + nil, + }, {"excluded-drivers.json", Enforcements{ExcludedDrivers: []DriverExclude{ {DriverClass: "UnitTest"}, diff --git a/enforcement/testdata/unhide.json b/enforcement/testdata/unhide.json new file mode 100644 index 0000000..354538f --- /dev/null +++ b/enforcement/testdata/unhide.json @@ -0,0 +1,10 @@ +{ + "unhide": [ + "4018073", + "67891011" + ], + "unhide-UpdateID": [ + "8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2", + "245bd515-7b95-496e-acac-344881833263" + ] +} diff --git a/hide.go b/hide.go index b3cc76b..1b32aeb 100644 --- a/hide.go +++ b/hide.go @@ -18,51 +18,83 @@ import ( "fmt" "os" "path/filepath" + "strings" "flag" "github.com/google/cabbie/cablib" "github.com/google/cabbie/search" "github.com/google/cabbie/session" "github.com/google/cabbie/updatecollection" + "github.com/google/cabbie/updates" "github.com/google/deck" "github.com/google/subcommands" + "github.com/google/glazier/go/helpers" ) +const ( + // skipUnrelated indicates the update was not requested to be unhidden. + skipUnrelated unhideAction = iota + // skipConflict indicates the update was requested to be unhidden but is also + // explicitly hidden, so it is left hidden. + skipConflict + // applyUnhide indicates the update should be made visible. + applyUnhide +) + +// unhideAction describes what unhide does with a single candidate update. +type unhideAction int + // Available flags type hideCmd struct { - kbs string - unhide bool + kbs string + updateIDs string + unhide bool +} + +// updateSet is a group of updates by KB, updateID, or both. +type updateSet struct { + kbs KBSet + uuids []string } func (hideCmd) Name() string { return "hide" } func (hideCmd) Synopsis() string { return "hide available updates" } func (hideCmd) Usage() string { - return fmt.Sprintf("%s hide [--unhide] [--kbs=\"\"]", filepath.Base(os.Args[0])) + return fmt.Sprintf("%s hide [--unhide] [--kbs=\"\"] [--update-ids=\"\"]", filepath.Base(os.Args[0])) } func (c *hideCmd) SetFlags(f *flag.FlagSet) { f.StringVar(&c.kbs, "kbs", "", "comma separated list of KB numbers to be hidden.") + f.StringVar(&c.updateIDs, "update-ids", "", "comma separated list of update IDs to be hidden.") f.BoolVar(&c.unhide, "unhide", false, "mark a hidden update as visible.") } func (c hideCmd) Execute(_ context.Context, flags *flag.FlagSet, _ ...any) subcommands.ExitStatus { kbs := NewKBSet(c.kbs) + updateIDs := helpers.StringToSlice(c.updateIDs) - if kbs.Size() < 1 { + if kbs.Size() < 1 && len(updateIDs) < 1 { fmt.Printf("%s\nUsage: %s\n", c.Synopsis(), c.Usage()) return subcommands.ExitUsageError } if c.unhide { - if err := unhide(kbs); err != nil { + if err := unhide(updateSet{kbs: kbs, uuids: updateIDs}, updateSet{}); err != nil { fmt.Println(err) deck.ErrorfA("Error unhiding an update: %v", err).With(eventID(cablib.EvtErrUnhide)).Go() } return subcommands.ExitSuccess } - if err := hide(kbs); err != nil { - fmt.Println(err) + if kbs.Size() > 0 { + if err := hide(kbs); err != nil { + fmt.Println(err) + } + } + if len(updateIDs) > 0 { + if err := hideByUpdateID(updateIDs); err != nil { + fmt.Println(err) + } } return subcommands.ExitSuccess } @@ -85,7 +117,49 @@ func findUpdates(criteria string) (*updatecollection.Collection, error) { return q.QueryUpdates() } -func unhide(kbs KBSet) error { +// empty reports whether the set identifies no updates at all. +func (s updateSet) empty() bool { + return s.kbs.Size() < 1 && len(s.uuids) < 1 +} + +// matches reports whether u is identified by this set, by either identifier. +func (s updateSet) matches(u *updates.Update) bool { + return s.kbs.Search(u.KBArticleIDs) || matchUpdateID(s.uuids, u.Identity.UpdateID) +} + +func (a unhideAction) String() string { + switch a { + case skipUnrelated: + return "skipUnrelated" + case skipConflict: + return "skipConflict" + case applyUnhide: + return "applyUnhide" + } + return fmt.Sprintf("unhideAction(%d)", int(a)) +} + +// decideUnhide reports what should happen to u given the set of updates +// requested to be unhidden and the set of updates that are explicitly hidden. +// Hiding wins if both are specified. +func decideUnhide(want, hidden updateSet, u *updates.Update) unhideAction { + switch { + case !want.matches(u): + return skipUnrelated + case hidden.matches(u): + return skipConflict + default: + return applyUnhide + } +} + +// unhide makes hidden updates visible again. An update is unhidden if it +// matches any of the passed KB article IDs or any of the passed update IDs. +func unhide(want, hidden updateSet) error { + if want.empty() { + return nil + } + // Find hidden updates. uc, err := findUpdates("IsHidden=1") if err != nil { @@ -96,17 +170,33 @@ func unhide(kbs KBSet) error { deck.InfofA("Found %d matching updates.", len(uc.Updates)).With(eventID(cablib.EvtUnhide)).Go() for _, u := range uc.Updates { - if kbs.Search(u.KBArticleIDs) { - deck.InfofA("Unhiding update:\n%s", u.Title).With(eventID(cablib.EvtUnhide)).Go() - if err := u.UnHide(); err != nil { - deck.ErrorfA("Failed to unhide update %s:\n %s", u.Title, err).With(eventID(cablib.EvtErrUnhide)).Go() - } + switch decideUnhide(want, hidden, u) { + case skipUnrelated: + continue + case skipConflict: + deck.ErrorfA("Ignoring unhide for update %q (UpdateID: %s, KBs: %v) because it is also explicitly hidden.", + u.Title, u.Identity.UpdateID, u.KBArticleIDs).With(eventID(cablib.EvtErrEnforcement)).Go() + continue + } + deck.InfofA("Unhiding update:\n%s", u.Title).With(eventID(cablib.EvtUnhide)).Go() + if err := u.UnHide(); err != nil { + deck.ErrorfA("Failed to unhide update %s:\n %s", u.Title, err).With(eventID(cablib.EvtErrUnhide)).Go() } } return nil } +// matchUpdateID reports whether updateID is present in uuids. +func matchUpdateID(uuids []string, updateID string) bool { + for _, uuid := range uuids { + if strings.EqualFold(uuid, updateID) { + return true + } + } + return false +} + func hide(kbs KBSet) error { // Find non-hidden updates that are installed or not installed. uc, err := findUpdates("IsHidden=0 and IsInstalled=0 or IsHidden=0 and IsInstalled=1") @@ -140,13 +230,12 @@ func hideByUpdateID(uuids []string) error { deck.InfofA("Found %d matching updates.", len(uc.Updates)).With(eventID(cablib.EvtHide)).Go() for _, u := range uc.Updates { - for _, uuid := range uuids { - if uuid == u.Identity.UpdateID { - deck.InfofA("Hiding update by UpdateID:\n%s", u.Title).With(eventID(cablib.EvtHide)).Go() - if err := u.Hide(); err != nil { - deck.ErrorfA("Failed to hide update %s:\n %s", u.Title, err).With(eventID(cablib.EvtErrHide)).Go() - } - } + if !matchUpdateID(uuids, u.Identity.UpdateID) { + continue + } + deck.InfofA("Hiding update by UpdateID:\n%s", u.Title).With(eventID(cablib.EvtHide)).Go() + if err := u.Hide(); err != nil { + deck.ErrorfA("Failed to hide update %s:\n %s", u.Title, err).With(eventID(cablib.EvtErrHide)).Go() } } diff --git a/hide_test.go b/hide_test.go new file mode 100644 index 0000000..d1ce5d5 --- /dev/null +++ b/hide_test.go @@ -0,0 +1,163 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package main + +import ( + "testing" + + "github.com/google/cabbie/updates" +) + +const ( + testUpdateID = "8870bdb3-95f9-43d9-9ba4-1f0e7cf13db2" + otherUpdateID = "245bd515-7b95-496e-acac-344881833263" +) + +// cumulative is a typical update carrying both a KB article ID and an update +// ID, so it can be targeted by either identifier. +func cumulative() *updates.Update { + return &updates.Update{ + Title: "Cumulative Update for Windows", + KBArticleIDs: []string{"5034441"}, + Identity: updates.Identity{UpdateID: testUpdateID}, + } +} + +func TestUpdateSetEmpty(t *testing.T) { + for _, tt := range []struct { + desc string + in updateSet + want bool + }{ + {"zero value", updateSet{}, true}, + {"empty kb list", updateSet{kbs: NewKBSet("")}, true}, + {"nil kb slice and nil uuids", updateSet{kbs: NewKBSetFromSlice(nil), uuids: nil}, true}, + {"empty uuid slice", updateSet{uuids: []string{}}, true}, + {"kbs only", updateSet{kbs: NewKBSet("KB5034441")}, false}, + {"uuids only", updateSet{uuids: []string{testUpdateID}}, false}, + {"both", updateSet{kbs: NewKBSet("KB5034441"), uuids: []string{testUpdateID}}, false}, + } { + t.Run(tt.desc, func(t *testing.T) { + if got := tt.in.empty(); got != tt.want { + t.Errorf("updateSet.empty() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestUpdateSetMatches(t *testing.T) { + // A driver update has no KB article ID, so it is only addressable by update ID. + driver := &updates.Update{ + Title: "Intel - Net", + Identity: updates.Identity{UpdateID: testUpdateID}, + } + multiKB := &updates.Update{ + Title: "Update with several KBs", + KBArticleIDs: []string{"5049296", "5034441"}, + Identity: updates.Identity{UpdateID: testUpdateID}, + } + + for _, tt := range []struct { + desc string + in updateSet + u *updates.Update + want bool + }{ + {"empty set matches nothing", updateSet{}, cumulative(), false}, + {"kb match", updateSet{kbs: NewKBSet("5034441")}, cumulative(), true}, + {"kb match with prefix in set", updateSet{kbs: NewKBSet("KB5034441")}, cumulative(), true}, + {"kb match with prefix on update", updateSet{kbs: NewKBSet("5034441")}, &updates.Update{KBArticleIDs: []string{"KB5034441"}}, true}, + {"kb mismatch", updateSet{kbs: NewKBSet("5049296")}, cumulative(), false}, + {"kb match against one of several", updateSet{kbs: NewKBSet("5034441")}, multiKB, true}, + {"kb set against update with no kbs", updateSet{kbs: NewKBSet("5034441")}, driver, false}, + {"update id match", updateSet{uuids: []string{testUpdateID}}, cumulative(), true}, + {"update id match is case insensitive", updateSet{uuids: []string{"8870BDB3-95F9-43D9-9BA4-1F0E7CF13DB2"}}, cumulative(), true}, + {"update id match against update with no kbs", updateSet{uuids: []string{testUpdateID}}, driver, true}, + {"update id mismatch", updateSet{uuids: []string{otherUpdateID}}, cumulative(), false}, + {"matches on update id when kb does not match", updateSet{kbs: NewKBSet("5049296"), uuids: []string{testUpdateID}}, cumulative(), true}, + {"matches on kb when update id does not match", updateSet{kbs: NewKBSet("5034441"), uuids: []string{otherUpdateID}}, cumulative(), true}, + {"neither identifier matches", updateSet{kbs: NewKBSet("5049296"), uuids: []string{otherUpdateID}}, cumulative(), false}, + } { + t.Run(tt.desc, func(t *testing.T) { + if got := tt.in.matches(tt.u); got != tt.want { + t.Errorf("updateSet.matches(%q) = %t, want %t", tt.u.Title, got, tt.want) + } + }) + } +} + +func TestDecideUnhide(t *testing.T) { + for _, tt := range []struct { + desc string + want updateSet + hidden updateSet + out unhideAction + }{ + { + "update was not requested to be unhidden", + updateSet{kbs: NewKBSet("5049296")}, + updateSet{}, + skipUnrelated, + }, + { + "requested by kb", + updateSet{kbs: NewKBSet("5034441")}, + updateSet{}, + applyUnhide, + }, + { + "requested by update id", + updateSet{uuids: []string{testUpdateID}}, + updateSet{}, + applyUnhide, + }, + { + "hidden by the same kb", + updateSet{kbs: NewKBSet("5034441")}, + updateSet{kbs: NewKBSet("5034441")}, + skipConflict, + }, + { + "hidden by the same update id", + updateSet{uuids: []string{testUpdateID}}, + updateSet{uuids: []string{testUpdateID}}, + skipConflict, + }, + { + // reconcile cannot catch this, as it only compares configured strings. + "hidden by kb, requested to be unhidden by update id", + updateSet{uuids: []string{testUpdateID}}, + updateSet{kbs: NewKBSet("5034441")}, + skipConflict, + }, + { + "hidden by update id, requested to be unhidden by kb", + updateSet{kbs: NewKBSet("5034441")}, + updateSet{uuids: []string{testUpdateID}}, + skipConflict, + }, + { + "hidden set covers only other updates", + updateSet{kbs: NewKBSet("5034441")}, + updateSet{kbs: NewKBSet("5049296"), uuids: []string{otherUpdateID}}, + applyUnhide, + }, + } { + t.Run(tt.desc, func(t *testing.T) { + if got := decideUnhide(tt.want, tt.hidden, cumulative()); got != tt.out { + t.Errorf("decideUnhide() = %v, want %v", got, tt.out) + } + }) + } +}