Conformance suite#

tests/conformance holds the spec cases as an importable package rather than as test files, so anything can replay them.

  • The core module runs them with no router in between.
  • Each adapter module runs the same cases through its own wrapper.
  • An adapter this repo does not ship can import the package and do the same.

Running it#

go test ./tests -run TestConformance -v

Through a specific adapter:

cd adapter/fiber && go test ./... -race -v

How it is put together#

Cases are values, not functions scattered across files:

var hubCases = []testCase{
	{name: "accepts a subscription request with 202", run: hubAccepts},
	{name: "refuses a perpetual lease", modes: []websub.Mode{websub.ModeWebSub}, run: hubRefusesPerpetual},
	{name: "grants a perpetual lease", modes: []websub.Mode{websub.ModePuSH04}, run: hubGrantsPerpetual},
}

A case with no modes runs against both specifications. A case that names one runs only there, which keeps behavior the two specs share from being tested twice for nothing, and makes the places they disagree obvious in the source.

Every fixture on the other side of an exchange is hand written and does not use this library: fakeHub speaks the hub side of the protocol without being a websub.Hub, and fakeSubscriber answers challenges without being a websub.Subscriber. That matters. If both ends shared an implementation, a case could pass because both ends make the same mistake.

Configuring a run#

type Config struct {
	Wrap  func(http.Handler) http.Handler
	Serve func(t *testing.T, h http.Handler) string
	Modes []websub.Mode
}

Wrap mounts a handler behind a router that is itself an http.Handler, which covers most of them. Serve is for frameworks that are not built on net/http and have to run their own server. Set at most one.

The handler must be mounted so every path below the root reaches it, since a subscriber’s callbacks live under a prefix rather than at one exact path.

The passthrough suite#

RunPassthrough is a smaller, separate set of cases checking that a router preserves what the protocol depends on:

  • Request bodies arrive byte for byte, because a signature is computed over exactly those bytes
  • Repeated Link headers stay separate values, because discovery reads several from one message
  • X-Hub-Signature survives, and still verifies against the delivered body
  • Query parameters survive encoding, because a challenge can contain anything
  • Response status codes are preserved, because 404 and 202 are protocol signals
  • Repeated response headers are preserved, because that is how a publisher advertises

Run covers these indirectly, but a failure there is hard to read. These fail with the exact byte, header, or status that went missing.

What is covered#

See the conformance checklist in the README for the current list, which is kept in step with the cases.

Adding a case#

  1. Write the case function in subscriber_cases.go, publisher_cases.go, or hub_cases.go, taking (t *testing.T, cfg Config, mode websub.Mode).
  2. Add it to the slice at the top of that file. Set modes only if the two specifications genuinely differ here.
  3. Link the spec section in a comment next to the assertion.
  4. Run it through an adapter as well as through core. If it passes in core and fails in fiber, you have found something real.
  5. Add a line to the README checklist.

A case belongs here if it tests protocol behavior over HTTP. A case testing this library’s own API, functional options, error values, retry policy, belongs in the module’s tests/ package instead.

Fuzzing#

Signature verification is fuzzed, because a forged X-Hub-Signature accepted through a parsing bug is the worst thing that can happen here. The target asserts two properties: it never panics, and it never accepts a header that was not produced from the same secret and body.

go test ./tests -run '^$' -fuzz FuzzVerify -fuzztime 60s

CI runs it on every pull request. If it finds a failure, commit the corpus entry it writes under tests/testdata/fuzz/.