diff --git a/content_filter.go b/content_filter.go index 9915b74..59a4dca 100644 --- a/content_filter.go +++ b/content_filter.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -522,15 +523,23 @@ func (f *ContentFilter) evaluateRule(rule FilterRule, app AppInfo, effectiveRati case "eq": return app.Category == rule.Value case "contains": - return app.Category == rule.Value + return strings.Contains(app.Category, rule.Value) } case "author": switch rule.Operator { case "eq": - return app.Author == rule.Value + // Addresses have two renderings of one key: a contract always stores the + // dero1 form (ADDRESS_STRING leaves Mainnet true so consensus cannot depend + // on the node's network), while a wallet on a simulator shows deto1. They are + // bech32, so prefix AND checksum differ and a string compare reads one wallet + // as two. Compare the decoded keys, as UpdateINDEX does. + return sameINDEXAuthor(app.Author, rule.Value) case "contains": - return app.Author == rule.Value + // A substring of an address cannot be decoded, so this stays a text match. + // An author rule naming a whole address must use "eq" to survive the two + // renderings above. + return strings.Contains(app.Author, rule.Value) } } diff --git a/content_filter_rules_test.go b/content_filter_rules_test.go new file mode 100644 index 0000000..cae60b3 --- /dev/null +++ b/content_filter_rules_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 HOLOGRAM Project. All rights reserved. +// Regression tests for evaluateRule's author and category rules (issue #27). + +package main + +import "testing" + +// Two renderings of ONE key. A contract stores the dero1 form on every network, +// because ADDRESS_STRING builds through rpc.NewAddressFromKeys and leaves Mainnet +// true; consensus cannot depend on which network a node believes it is on. A wallet +// opened on a simulator shows the deto1 form of the same key. Addresses are bech32, +// so the prefix names the network and the checksum is computed over it: both halves +// of the string differ. +const ( + authorMainnet = "dero1qyw4fl3dupcg5qlrcsvcedze507q9u67lxfpu8kgnzp04aq73yheqqg2ctjn4" + authorSimulator = "deto1qyw4fl3dupcg5qlrcsvcedze507q9u67lxfpu8kgnzp04aq73yheqqgsph2ka" + otherAuthor = "dero1qy976ssakhfynpd4lnh39u7gw9spfzr9z55ckfd0yhrhsdr235glgqq28xlvm" +) + +func ruleFor(t, op, v string) FilterRule { + return FilterRule{Type: t, Operator: op, Value: v, Enabled: true} +} + +// TestAuthorRuleEqMatchesAcrossNetworkRendering is the half of issue #27 that made an +// author rule unusable the moment anyone could write one: the rule value is whatever +// the user copied out of their wallet, and the app's author is whatever the contract +// stored, so on a simulator the two are the same key spelled two ways. +func TestAuthorRuleEqMatchesAcrossNetworkRendering(t *testing.T) { + if authorMainnet == authorSimulator { + t.Fatal("the two vectors are identical; this test proves nothing") + } + + cases := []struct { + name string + appAuthor string + ruleValue string + wantMatch bool + }{ + {"same rendering", authorMainnet, authorMainnet, true}, + {"app mainnet, rule simulator", authorMainnet, authorSimulator, true}, + {"app simulator, rule mainnet", authorSimulator, authorMainnet, true}, + {"different author", authorMainnet, otherAuthor, false}, + {"different author across networks", authorSimulator, otherAuthor, false}, + {"rule is not an address", authorMainnet, "nobody", false}, + {"app author empty", "", authorMainnet, false}, + } + + f := &ContentFilter{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := AppInfo{Author: tc.appAuthor} + got := f.evaluateRule(ruleFor("author", "eq", tc.ruleValue), app, 0) + if got != tc.wantMatch { + t.Fatalf("author eq %q vs %q = %v, want %v", + tc.appAuthor, tc.ruleValue, got, tc.wantMatch) + } + }) + } +} + +// TestContainsOperatorIsSubstring is the other half of issue #27: both contains arms +// were a copy of their eq arm, so "contains" behaved as exact equality and a rule +// using it could never match a longer string. +func TestContainsOperatorIsSubstring(t *testing.T) { + f := &ContentFilter{} + + cases := []struct { + name string + ruleType string + app AppInfo + value string + wantMatch bool + }{ + {"category substring", "category", AppInfo{Category: "games/arcade"}, "arcade", true}, + {"category exact still matches", "category", AppInfo{Category: "games"}, "games", true}, + {"category absent", "category", AppInfo{Category: "games"}, "finance", false}, + {"author substring", "author", AppInfo{Author: authorMainnet}, "qyw4fl3", true}, + {"author absent", "author", AppInfo{Author: authorMainnet}, "zzzzzz", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := f.evaluateRule(ruleFor(tc.ruleType, "contains", tc.value), tc.app, 0) + if got != tc.wantMatch { + t.Fatalf("%s contains %q = %v, want %v", tc.ruleType, tc.value, got, tc.wantMatch) + } + }) + } + + // Non-vacuity: the substring cases above must be ones an equality compare would + // REJECT, or this test would pass against the defect it exists to catch. + if "games/arcade" == "arcade" || authorMainnet == "qyw4fl3" { + t.Fatal("the substring vectors are exact matches; this test cannot see the defect") + } +} + +// TestUnknownRuleShapesStayClosed pins the surrounding behaviour the fix must not +// widen: an unknown type or operator matches nothing. +func TestUnknownRuleShapesStayClosed(t *testing.T) { + f := &ContentFilter{} + app := AppInfo{Author: authorMainnet, Category: "games"} + + if f.evaluateRule(ruleFor("keyword", "eq", "games"), app, 0) { + t.Fatal("an unimplemented rule type must not match") + } + if f.evaluateRule(ruleFor("author", "not_contains", authorMainnet), app, 0) { + t.Fatal("an unimplemented operator must not match") + } +}