From 0fc3b21f458813358ee2d3204056bc5b7bbd97ea Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Thu, 8 Oct 2026 20:52:47 +0200 Subject: [PATCH] =?UTF-8?q?test(contract):=20SSID=20matcher=20fence=20?= =?UTF-8?q?=E2=80=94=20pinned=20acceptance=20table=20for=20hasTollGateSSID?= =?UTF-8?q?=20(#618/#706=20classes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader twin of check-ssid-format.sh: what the WRITER may emit is pinned there, what the MATCHER must accept is pinned here. One fixture table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit test and tests/contract/check-ssid-naming.sh (structural: table covers every required class and the Go pin is wired; behavioural: the real go test runs over the table). Classes: both brands bare (#618 — the matcher was brand-blind and a whitelabel upstream was invisible), case- insensitive (lowercase installer-era SSIDs still in the fleet), single-'!'-decorated with double-'!' rejected (#706). Lands #706's reader-side tolerance (trim at most one '!') so the table is green on merge; #706 then only needs its writer/installer half. The whitelabel fixtures carry the brand literal in the table's file — the SSID contract's allowlisted home in the rebrand gutter (same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes. Negative control: a planted contradictory fixture line turns the fence red (behavioural layer), removed again. Refs #767 --- .github/workflows/test.yml | 10 ++ .ngit/act/workflows/test.yml | 2 + hooks/pre-commit | 10 ++ src/wireless_gateway_manager/brands.go | 17 ++- src/wireless_gateway_manager/brands_test.go | 72 ++++++---- tests/contract/check-ssid-naming.sh | 129 ++++++++++++++++++ tests/contract/ssid-naming-fixtures.txt | 61 +++++++++ .../packaging/rebrand-literal-gutter_test.sh | 7 +- 8 files changed, 275 insertions(+), 33 deletions(-) create mode 100755 tests/contract/check-ssid-naming.sh create mode 100644 tests/contract/ssid-naming-fixtures.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de80f8883..4efe479ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -302,6 +302,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 diff --git a/.ngit/act/workflows/test.yml b/.ngit/act/workflows/test.yml index e2d085d25..4b8c495f0 100644 --- a/.ngit/act/workflows/test.yml +++ b/.ngit/act/workflows/test.yml @@ -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 diff --git a/hooks/pre-commit b/hooks/pre-commit index bb0c2bd52..a298d04b5 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -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 diff --git a/src/wireless_gateway_manager/brands.go b/src/wireless_gateway_manager/brands.go index 53ee6ab28..d328dfaf3 100644 --- a/src/wireless_gateway_manager/brands.go +++ b/src/wireless_gateway_manager/brands.go @@ -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 } } diff --git a/src/wireless_gateway_manager/brands_test.go b/src/wireless_gateway_manager/brands_test.go index e7c3556c3..fe7e79244 100644 --- a/src/wireless_gateway_manager/brands_test.go +++ b/src/wireless_gateway_manager/brands_test.go @@ -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- 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) } } @@ -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) + } +} diff --git a/tests/contract/check-ssid-naming.sh b/tests/contract/check-ssid-naming.sh new file mode 100755 index 000000000..f727fb44e --- /dev/null +++ b/tests/contract/check-ssid-naming.sh @@ -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 (`-`, 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 ', 'nomatch ', a comment, or blank" +else + fail "malformed line(s): $(printf '%s' "$bad_lines" | head -3 | tr '\n' ' ')" +fi + +require_class() { # require_class --