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
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,16 @@ jobs:
run: bash tests/contract/build-purity.sh
- name: Shipped AP SSID format check
run: bash tests/contract/check-ssid-format.sh
- name: SSID naming contract (the matcher's acceptance table)
run: |
# The reader twin of the format check above: hasTollGateSSID must
# accept both brands bare AND single-'!'-decorated, case-
# insensitively, and reject a double '!' — pinned by ONE fixture
# table the Go unit test and this check both consume. The SSID
# recognition contract broke twice (#618 brand-blind matcher, #706
# decoration); each was a reader/writer disagreement this table
# would have caught.
bash tests/contract/check-ssid-naming.sh

deps-and-imports:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .ngit/act/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ jobs:
run: bash tests/contract/build-purity.sh
- name: Shipped AP SSID format check
run: bash tests/contract/check-ssid-format.sh
- name: SSID naming contract (the matcher's acceptance table)
run: bash tests/contract/check-ssid-naming.sh

deps-and-imports:
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ if git diff --cached --name-only | grep -qE '^packaging/files/'; then
bash tests/contract/check-ssid-format.sh
fi

# The reader twin of the format contract: hasTollGateSSID's acceptance table
# (both brands, bare and single-'!'-decorated, case-insensitive, double-!
# rejected) is pinned by tests/contract/ssid-naming-fixtures.txt, shared
# with the Go unit test. Runs when the matcher, the table, or the fence
# itself is staged.
if git diff --cached --name-only | grep -qE '^src/wireless_gateway_manager/brands|^tests/contract/(ssid-naming|check-ssid-naming)'; then
echo "🔍 Checking the SSID matcher's acceptance table..."
bash tests/contract/check-ssid-naming.sh
fi

# Changelog fragments carry a PR's changelog entry (changelog.d/README.md). A
# malformed one — wrong filename, unknown type, a body that is not the bullet —
# is refused here rather than when a release tries to fold it. The fold tool is
Expand Down
17 changes: 15 additions & 2 deletions src/wireless_gateway_manager/brands.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,24 @@ const whitelabelCaptivePrefix = "Net4" + "sats" + "-"
// agreement.
var tollGateBrandPrefixes = []string{"TollGate-", whitelabelCaptivePrefix}

// ssidDecoration is the optional leading '!' the captive-SSID writer may
// emit (#706: the guest SSID sorts first in alphabetically-ordered client
// lists). It is decoration, never a new brand prefix: recognition strips at
// most ONE occurrence, so a double '!!TollGate-…' is explicitly not a
// TollGate name (a fixture class, not an accident).
const ssidDecoration = "!"

// hasTollGateSSID reports whether ssid starts with any brand prefix the
// captive-SSID writer can emit, case-insensitively.
// captive-SSID writer can emit, case-insensitively, with at most one
// leading '!' decoration stripped first. The acceptance table both this
// helper and the contract check run against is pinned in
// tests/contract/ssid-naming-fixtures.txt (#618: the matcher was once
// brand-blind; #706: the decoration arrived) — keep table and prefixes in
// agreement.
func hasTollGateSSID(ssid string) bool {
name := strings.TrimPrefix(ssid, ssidDecoration)
for _, prefix := range tollGateBrandPrefixes {
if len(ssid) >= len(prefix) && strings.EqualFold(ssid[:len(prefix)], prefix) {
if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) {
return true
}
}
Expand Down
72 changes: 42 additions & 30 deletions src/wireless_gateway_manager/brands_test.go
Original file line number Diff line number Diff line change
@@ -1,45 +1,45 @@
package wireless_gateway_manager

import (
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

// Pins the SSID recognition contract shared with the first-boot writer
// (99-tollgate-setup load_brand) and the installer's brandingCommands:
// both brands' captive prefixes, case-insensitively (the pre-device-code
// installer wrote lowercase tollgate-<code> SSIDs; those routers are still
// in the fleet). A rename or a foreign brand is not a TollGate.
// The pinned acceptance table for hasTollGateSSID: ONE file, read by this
// test (the matcher's behaviour) and by tests/contract/check-ssid-naming.sh
// (the table's structure and wiring). The classes it pins are the two
// measured SSID recognition breaks: the brand-blind matcher (#618, re-landed
// as #682) and the single-'!' writer decoration (#706).
const ssidFixturePath = "../../tests/contract/ssid-naming-fixtures.txt"

func TestHasTollGateSSIDRecognitionSet(t *testing.T) {
recognized := []string{
"TollGate-OQ3Q", // current writer, default brand
"TollGate-A1B2", // hex-era code
"TollGate-G7ZQ", // adopted alphanumeric code
// whitelabelCaptivePrefix is assembled in source (see brands.go:
// the rebrand gutter bans the spelled-out name), so its fixtures
// derive from the constant; the value itself is pinned by
// TestWhitelabelCaptivePrefixValue below.
whitelabelCaptivePrefix + "OQ3Q", // whitelabel brand
"tollgate-0GLK", // old-installer lowercase SSID, still deployed
strings.ToUpper(whitelabelCaptivePrefix) + "AA11", // case-insensitive whitelabel
"TollGate-", // bare prefix, code length is not this helper's concern
}
for _, ssid := range recognized {
assert.True(t, hasTollGateSSID(ssid), "expected recognized: %s", ssid)
data, err := os.ReadFile(ssidFixturePath)
if err != nil {
t.Fatalf("SSID fixture table unreadable: %v — the pinned recognition contract is missing", err)
}

rejected := []string{
"", // empty
"TollGate", // no separator
"TollGatex-AB12", // prefix must be exact up to case
"MyTollGate-AB12", // prefix must be at the start
"FreeWifi", // unrelated
"tollgate", // bare brand, lowercase
matches, rejects := 0, 0
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
verdict, ssid, _ := strings.Cut(line, " ")
switch verdict {
case "match":
matches++
assert.True(t, hasTollGateSSID(ssid), "expected recognized: %q", ssid)
case "nomatch":
rejects++
assert.False(t, hasTollGateSSID(ssid), "expected rejected: %q", ssid)
default:
t.Fatalf("fixture line %q is neither 'match' nor 'nomatch' — the table is malformed", line)
}
}
for _, ssid := range rejected {
assert.False(t, hasTollGateSSID(ssid), "expected rejected: %s", ssid)
if matches < 10 || rejects < 8 {
t.Fatalf("fixture table too thin (%d match, %d reject) — required classes are missing", matches, rejects)
}
}

Expand All @@ -51,3 +51,15 @@ func TestWhitelabelCaptivePrefixValue(t *testing.T) {
assert.Equal(t, "Net4"+"sats-", whitelabelCaptivePrefix)
assert.Contains(t, tollGateBrandPrefixes, whitelabelCaptivePrefix)
}

// The decoration contract from #706, independent of the table: at most one
// '!' is stripped, and stripping is idempotent per spelling — the decorated
// and bare forms of one router's SSID recognise identically, a double does
// not recognise at all.
func TestSSIDDecorationIsSingleOptional(t *testing.T) {
for _, prefix := range tollGateBrandPrefixes {
assert.True(t, hasTollGateSSID(prefix+"A1B2"), "bare form must recognise: %s", prefix)
assert.True(t, hasTollGateSSID(ssidDecoration+prefix+"A1B2"), "decorated form must recognise: %s", prefix)
assert.False(t, hasTollGateSSID(ssidDecoration+ssidDecoration+prefix+"A1B2"), "double decoration must not recognise: %s", prefix)
}
}
129 changes: 129 additions & 0 deletions tests/contract/check-ssid-naming.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Contract: the SSID MATCHER's acceptance table (the reader side).
#
# tests/contract/check-ssid-format.sh pins what the first-boot WRITER may
# emit (`<brand>-<device code>`, one name on both radios). This fence pins
# what the READER — src/wireless_gateway_manager hasTollGateSSID, the
# reseller-mode upstream selector and the vendor-element score input —
# must accept, against the same pinned table the Go unit test reads
# (tests/contract/ssid-naming-fixtures.txt). Two layers, because neither
# is sufficient alone:
#
# A. structural — the table is well-formed and covers every REQUIRED
# class: both brands (#618: the matcher was brand-blind and a
# whitelabel upstream was invisible), case-insensitivity (lowercase
# installer-era SSIDs are still in the fleet), the single-'!'
# decoration (#706's writer, both spellings must converge on the same
# recognition), the double-'!' rejection, and the anchored negatives.
# Also: the Go test must actually reference the table — a pin nobody
# loads is decoration.
# B. behavioural — the Go test over the table runs and passes. This is
# the matcher itself, not a grep of its source.
#
# The SSID contract broke twice (#618, #706); each break was a reader/writer
# disagreement this table would have caught.
#
# Exit 0 when the contract holds, 1 otherwise. Layer B needs go on PATH —
# this repo is a Go tree; a missing toolchain is a failure, not a skip.
set -uo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
TABLE="$ROOT/tests/contract/ssid-naming-fixtures.txt"
BRANDS_GO="$ROOT/src/wireless_gateway_manager/brands.go"
BRANDS_TEST="$ROOT/src/wireless_gateway_manager/brands_test.go"

fails=0
pass() { printf ' ok %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1" >&2; fails=$((fails + 1)); }
info() { printf ' info %s\n' "$1"; }

printf 'check-ssid-naming: root %s\n' "$ROOT"

if [ ! -f "$TABLE" ]; then
fail "$TABLE is missing; the matcher's acceptance table is gone"
printf '\n1 SSID-naming check FAILED.\n' >&2
exit 1
fi

# The whitelabel prefix, spelled the gutter's way (bracket expressions): this
# file must not carry the re-brand's name as contiguous bytes either.

# --- A. structural ----------------------------------------------------------
printf '\n--- A. the table is well-formed and covers every class ---\n'

bad_lines="$(grep -vE '^(match|nomatch)( .*)?$|^#.*$|^$' "$TABLE" || true)"
if [ -z "$bad_lines" ]; then
pass "every line is 'match <ssid>', 'nomatch <ssid>', a comment, or blank"
else
fail "malformed line(s): $(printf '%s' "$bad_lines" | head -3 | tr '\n' ' ')"
fi

require_class() { # require_class <grep-args...> -- <label>
local pattern="$1" label="$2" count
count="$(grep -cE -- "$pattern" "$TABLE" || true)"
if [ "${count:-0}" -ge 1 ]; then
pass "$label"
else
fail "required class missing ($label): no line matches /$pattern/"
fi
}

require_class '^match TollGate-[A-Z0-9]+' 'default brand, bare, canonical case'
require_class '^match tollgate-' 'default brand, bare, lowercase (installer era)'
require_class '^match !TollGate-' 'default brand, single-!-decorated'
require_class '^match !tollgate-' 'default brand, single-!-decorated, lowercase'
# [Nn]et4[s]ats: the bracket expressions keep this file's bytes from forming
# the whitelabel name contiguously (the rebrand gutter's own idiom) while
# the regex still matches the canonical and lowercase spellings.
require_class '^match [Nn]et4[s]ats-' 'whitelabel brand, bare, canonical case'
require_class '^match net4[s]ats-' 'whitelabel brand, bare, lowercase'
require_class '^match ![Nn]et4[s]ats-' 'whitelabel brand, single-!-decorated, canonical case'
require_class '^match !net4[s]ats-' 'whitelabel brand, single-!-decorated, lowercase'
require_class '^nomatch !!TollGate-' 'double-! rejection, default brand (#706)'
require_class '^nomatch !![Nn]et4[s]ats-' 'double-! rejection, whitelabel brand'
require_class '^nomatch MyTollGate-' 'anchored prefix negative (no leading substring match)'
require_class '^nomatch TollGatex-' 'exact-prefix negative'
require_class '^nomatch$' 'the empty SSID is not a TollGate'

wl_any="$(grep -icE "^match (!?)[Nn]et4[s]ats-" "$TABLE" || true)"
wl_lower="$(grep -cE "^match (!?)net4[s]ats-" "$TABLE" || true)"
if [ "${wl_any:-0}" -ge 4 ] && [ "${wl_lower:-0}" -ge 2 ]; then
pass "whitelabel coverage: ${wl_any} case-insensitive lines, ${wl_lower} lowercase"
else
fail "whitelabel coverage too thin: ${wl_any} case-insensitive lines, ${wl_lower} lowercase"
fi

# The Go pin must be wired: brands_test.go reads THIS file by name, so the
# table cannot drift out from under the unit test.
if grep -qF 'ssid-naming-fixtures.txt' "$BRANDS_TEST"; then
pass "brands_test.go loads the pinned table (the Go pin is wired)"
else
fail "brands_test.go no longer references ssid-naming-fixtures.txt — the table is decorative"
fi
if [ -f "$BRANDS_GO" ] && grep -qF 'ssidDecoration' "$BRANDS_GO"; then
pass "brands.go carries the decoration constant (single-'!' is a named contract, not an accident)"
else
fail "brands.go lost ssidDecoration — the #706 reader contract moved or was renamed"
fi

# --- B. behavioural ---------------------------------------------------------
printf '\n--- B. the matcher meets the table (real Go test) ---\n'

if ! command -v go >/dev/null 2>&1; then
fail "go is not on PATH — the behavioural layer cannot run (a skip would make this fence decoration)"
else
info "go $(go version | awk '{print $3}')"
if (cd "$ROOT/src/wireless_gateway_manager" && go test -count=1 -run 'TestHasTollGateSSID|TestWhitelabel|TestSSIDDecoration' .) >/tmp/ssid-naming-go.log 2>&1; then
pass "hasTollGateSSID accepts/rejects the pinned table (go test green)"
else
fail "the matcher no longer meets the pinned table — go test output:"
sed 's/^/ /' /tmp/ssid-naming-go.log >&2
fi
fi

printf '\n'
if [ "$fails" -ne 0 ]; then
printf '%d SSID-naming check(s) FAILED.\n' "$fails" >&2
exit 1
fi
printf 'The SSID matcher meets its pinned acceptance table (both brands, bare and single-!-decorated, case-insensitive, double-! rejected).\n'
61 changes: 61 additions & 0 deletions tests/contract/ssid-naming-fixtures.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# The SSID matcher's acceptance table — ONE table, two readers.
#
# * src/wireless_gateway_manager/brands_test.go drives hasTollGateSSID
# over every line of this file (behaviour: the Go matcher itself);
# * tests/contract/check-ssid-naming.sh proves this table is well-formed
# and covers every REQUIRED class, and that the Go test actually reads
# it (structure: the pin is wired, not decorative).
#
# Line grammar: 'match <ssid>' or 'nomatch <ssid>' (a bare 'nomatch' is the
# empty SSID); comment and blank lines are ignored; comments live on their
# own lines so parsing stays trivial.
#
# The contract, and where each class came from:
# * BOTH brands — the matcher was brand-blind until #618 (re-landed as
# #682): a whitelabel-branded TollGate was invisible as an upstream.
# * case-insensitive — the pre-device-code installer wrote lowercase
# tollgate-<code> SSIDs; those routers are still in the fleet.
# * single-'!'-decorated — #706's writer prefix, so the decorated and
# bare spellings of one router converge on the same recognition.
# * at most ONE '!' — a double decoration is not a TollGate name (#706).
#
# The whitelabel prefix appears literally HERE and nowhere else in the
# table's consumers: this file is the SSID contract's allowlisted home
# (tests/packaging/rebrand-literal-gutter_test.sh); Go source assembles it
# from non-contiguous bytes and brands_test.go pins that assembly.

# --- default brand, bare writer ---------------------------------------------
match TollGate-OQ3Q
match TollGate-A1B2
match TollGate-G7ZQ
match TollGate-
match tollgate-0GLK
match tOLLgATE-ab12

# --- default brand, single-'!'-decorated writer (#706) ----------------------
match !TollGate-9C3F
match !tollgate-9c3f

# --- whitelabel brand, bare writer ------------------------------------------
match Net4sats-OQ3Q
match net4sats-aa11
match NET4SATS-AA11

# --- whitelabel brand, single-'!'-decorated writer --------------------------
match !Net4sats-7D2K
match !net4sats-7d2k

# --- decoration is at most ONE '!' ------------------------------------------
nomatch !!TollGate-A1B2
nomatch !!Net4sats-A1B2
nomatch !tollgate

# --- prefix anchored at the start, exact up to case -------------------------
nomatch
nomatch TollGate
nomatch tollgate
nomatch TollGatex-AB12
nomatch MyTollGate-AB12
nomatch x!TollGate-AB12
nomatch FreeWifi
nomatch !FreeWifi
7 changes: 6 additions & 1 deletion tests/packaging/rebrand-literal-gutter_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ DISCUSSION_ALLOWLIST=(
'README.md'
'CONTRIBUTING.md'
'tests/contract/check-ssid-format.sh'
# The SSID matcher's pinned acceptance table: the whitelabel fixtures
# are the contract's own data, consumed by brands_test.go (which keeps
# the literal out of src/) and check-ssid-naming.sh. Same legitimate
# home as the format checker above it.
'tests/contract/ssid-naming-fixtures.txt'
)
scan_tree() { # scan_tree <extended-regex>
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
Expand All @@ -77,7 +82,7 @@ scan_tree() { # scan_tree <extended-regex>
git grep -I -i -E -- "$1" -- . "${excludes[@]}" 2>/dev/null
else
grep -rI -i -E -- "$1" . 2>/dev/null | grep -v '^\./\.git/' \
| grep -vE '^(\./)?(docs/|CHANGELOG\.md:|README\.md:|CONTRIBUTING\.md:|tests/contract/check-ssid-format\.sh:)' || true
| grep -vE '^(\./)?(docs/|CHANGELOG\.md:|README\.md:|CONTRIBUTING\.md:|tests/contract/check-ssid-format\.sh:|tests/contract/ssid-naming-fixtures\.txt:)' || true
fi
}

Expand Down