Files
OwnCord/Server/ws/protocol_contract_test.go
T
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00

244 lines
8.6 KiB
Go

package ws_test
// protocol_contract_test.go — locks protocol/schema.json and the
// generated ws/message_types.go together so the two cannot silently drift.
//
// message_types.go is documented as "Code generated by cmd/genprotocol
// from protocol/schema.json; DO NOT EDIT" and CI runs
// `make protocol-verify`, but that only re-runs the generator and diffs its
// output — it says nothing about message-type constants that exist in the ws
// package outside the generated file (e.g. a handler defining its own
// MsgTypeFoo instead of adding it to the schema). This test instead parses
// both the schema and every non-test .go file in this package for `MsgType*`
// string constants and asserts they agree in both directions:
//
// - every wire value the schema lists has a matching Go constant with the
// schema's stated name and value ("schema -> code"), and
// - every MsgType* constant declared anywhere in the ws package appears in
// the schema ("code -> schema").
//
// The exception list below is empty and should stay that way. Its one
// historical entry, MsgTypeChatCommand, predated the schema/genprotocol
// pipeline (plugin slash commands were judged internal wiring); the 2026-08-04
// remediation moved the whole plugin command family (chat_command,
// command_reply, plugin_broadcast) into the schema, closing DC-01. If an
// undocumented constant ever appears, this test fails and names it — that is
// the drift this test exists to catch, not something to silently allowlist.
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/J3vb/OwnCord/Server/ws"
)
var knownUndocumentedConstants = map[string]string{}
// protocolSchemaEntry mirrors one element of the client_to_server /
// server_to_client arrays in protocol/schema.json.
type protocolSchemaEntry struct {
Wire string `json:"wire"`
Go string `json:"go"`
TS string `json:"ts"`
}
type protocolSchema struct {
Version int `json:"version"`
ProtocolEpoch int `json:"protocol_epoch"`
ClientToServer []protocolSchemaEntry `json:"client_to_server"`
ServerToClient []protocolSchemaEntry `json:"server_to_client"`
}
// loadProtocolSchema locates and parses protocol/schema.json relative to
// this test file (ws/ -> Server/ -> repo root -> protocol/), so the test does not
// depend on the working directory `go test` happens to be invoked from.
func loadProtocolSchema(t *testing.T) protocolSchema {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed to resolve test file path")
}
wsDir := filepath.Dir(thisFile)
schemaPath := filepath.Join(wsDir, "..", "..", "protocol", "schema.json")
raw, err := os.ReadFile(schemaPath)
if err != nil {
t.Fatalf("reading protocol schema at %s: %v", schemaPath, err)
}
var schema protocolSchema
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("parsing protocol schema: %v", err)
}
return schema
}
// loadGoMsgTypeConstants statically parses every non-test .go file in the ws
// package directory and returns a map of MsgType* constant name -> its wire
// string value, for top-level `const ( Name = "value" )` declarations.
func loadGoMsgTypeConstants(t *testing.T) map[string]string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed to resolve test file path")
}
wsDir := filepath.Dir(thisFile)
entries, err := os.ReadDir(wsDir)
if err != nil {
t.Fatalf("reading ws package directory %s: %v", wsDir, err)
}
out := make(map[string]string)
fset := token.NewFileSet()
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, filepath.Join(wsDir, name), nil, 0)
if err != nil {
t.Fatalf("parsing %s: %v", name, err)
}
for _, decl := range file.Decls {
gen, isGen := decl.(*ast.GenDecl)
if !isGen || gen.Tok != token.CONST {
continue
}
for _, spec := range gen.Specs {
vspec, isVal := spec.(*ast.ValueSpec)
if !isVal {
continue
}
for i, ident := range vspec.Names {
if !strings.HasPrefix(ident.Name, "MsgType") {
continue
}
if i >= len(vspec.Values) {
continue // no explicit value on this line (e.g. iota-style)
}
lit, isLit := vspec.Values[i].(*ast.BasicLit)
if !isLit || lit.Kind != token.STRING {
continue
}
val, err := strconv.Unquote(lit.Value)
if err != nil {
t.Fatalf("unquoting %s = %s in %s: %v", ident.Name, lit.Value, name, err)
}
if existing, dup := out[ident.Name]; dup && existing != val {
t.Fatalf("constant %s declared twice with different values (%q vs %q) across ws package files",
ident.Name, existing, val)
}
out[ident.Name] = val
}
}
}
}
return out
}
// TestProtocolSchema_MatchesGeneratedGoConstants is the "schema -> code"
// direction: every wire constant protocol/schema.json lists (in either
// direction of traffic) must have a same-named Go constant in the ws package
// carrying exactly the schema's wire string.
func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) {
schema := loadProtocolSchema(t)
goConsts := loadGoMsgTypeConstants(t)
check := func(direction string, entries []protocolSchemaEntry) {
for _, e := range entries {
got, ok := goConsts[e.Go]
if !ok {
t.Errorf("%s: schema entry wire=%q go=%q has no matching Go constant in ws package",
direction, e.Wire, e.Go)
continue
}
if got != e.Wire {
t.Errorf("%s: ws.%s = %q, want %q per protocol/schema.json", direction, e.Go, got, e.Wire)
}
}
}
if len(schema.ClientToServer) == 0 {
t.Fatal("protocol/schema.json client_to_server is empty — schema failed to load")
}
if len(schema.ServerToClient) == 0 {
t.Fatal("protocol/schema.json server_to_client is empty — schema failed to load")
}
check("client_to_server", schema.ClientToServer)
check("server_to_client", schema.ServerToClient)
}
// TestProtocolSchema_NoUndocumentedGoConstants is the "code -> schema"
// direction: every MsgType* constant declared in the ws package must appear
// in protocol/schema.json, except the documented exceptions in
// knownUndocumentedConstants (see its doc comment). This is what catches a
// handler minting its own wire constant instead of adding it to the schema.
func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) {
schema := loadProtocolSchema(t)
goConsts := loadGoMsgTypeConstants(t)
documented := make(map[string]string, len(schema.ClientToServer)+len(schema.ServerToClient))
for _, e := range schema.ClientToServer {
documented[e.Go] = e.Wire
}
for _, e := range schema.ServerToClient {
documented[e.Go] = e.Wire
}
for name, wire := range goConsts {
if _, inSchema := documented[name]; inSchema {
continue
}
exceptWire, isException := knownUndocumentedConstants[name]
if !isException {
t.Errorf("ws.%s = %q is not in protocol/schema.json and is not a documented exception "+
"(knownUndocumentedConstants) — add it to the schema or the exception list", name, wire)
continue
}
if exceptWire != wire {
t.Errorf("documented exception ws.%s has wire value %q, but knownUndocumentedConstants says %q — update the exception list",
name, wire, exceptWire)
}
}
// Guard against the exception list growing silently: it must contain
// exactly the constants we can currently account for as intentionally
// undocumented, and nothing that has since been added to the schema.
for name := range knownUndocumentedConstants {
if _, stillMissing := goConsts[name]; !stillMissing {
t.Errorf("knownUndocumentedConstants lists %q but no such Go constant exists anymore — remove it from the exception list", name)
continue
}
if _, nowDocumented := documented[name]; nowDocumented {
t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol/schema.json — remove it from the exception list", name)
}
}
}
// TestProtocolEpochMatchesSchema pins the generated ws.ProtocolEpoch to the
// protocol_epoch the schema declares. The epoch is the one number the auth
// handshake negotiates on (serve_auth.go); a stale regeneration here would
// let server and client disagree about which epoch they speak.
func TestProtocolEpochMatchesSchema(t *testing.T) {
schema := loadProtocolSchema(t)
if schema.ProtocolEpoch < 1 {
t.Fatalf("schema protocol_epoch = %d, want >= 1", schema.ProtocolEpoch)
}
if ws.ProtocolEpoch != schema.ProtocolEpoch {
t.Fatalf("ws.ProtocolEpoch = %d, schema protocol_epoch = %d — run `make protocol-generate`",
ws.ProtocolEpoch, schema.ProtocolEpoch)
}
}