Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions content_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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)
}
}

Expand Down
109 changes: 109 additions & 0 deletions content_filter_rules_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading