mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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).
244 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|