Files
OwnCord/Server/api/client_update_epoch_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

74 lines
2.9 KiB
Go

package api_test
// client_update_epoch_test.go — the client-update endpoint never advertises a
// release whose signed manifest declares a protocol epoch newer than this
// server's (B2-2): a client that auto-updated onto it would be refused at the
// next handshake. A manifest that does not verify is treated the same way —
// fail closed, 204 — since its epoch cannot be trusted. A release with no
// manifest at all predates the epoch and is advertised as before
// (client_update_test.go covers that path throughout).
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/J3vb/OwnCord/Server/updater"
)
// fakeGitHubReleaseWithManifest is fakeGitHubRelease plus a server-update
// manifest and signature, served with the given bytes.
func fakeGitHubReleaseWithManifest(t *testing.T, tag string, manifest, sig []byte) *httptest.Server {
t.Helper()
var srv *httptest.Server
mux := http.NewServeMux()
assetNames := []string{
"OwnCord_1.0.0_x64-setup.nsis.zip",
"OwnCord_1.0.0_x64-setup.nsis.zip.sig",
"server-update-manifest.json",
"server-update-manifest.json.sig",
}
mux.HandleFunc("/repos/test/repo/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
assets := make([]map[string]any, 0, len(assetNames))
for _, name := range assetNames {
assets = append(assets, map[string]any{"name": name, "browser_download_url": srv.URL + "/download/" + name})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": tag, "body": "notes", "html_url": "x", "assets": assets})
})
mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "server-update-manifest.json"):
_, _ = w.Write(manifest)
case strings.HasSuffix(r.URL.Path, "server-update-manifest.json.sig"):
_, _ = w.Write(sig)
case strings.HasSuffix(r.URL.Path, ".sig"):
_, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ="))
default:
http.NotFound(w, r)
}
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestClientUpdate_UnverifiableManifestIsNotAdvertised(t *testing.T) {
// The manifest claims epoch 1 (compatible) but its signature is garbage:
// the claim is untrusted, so the release is withheld.
manifest := []byte(`{"version":"v2.0.0","asset":"chatserver.exe","sha256":"00","protocol_epoch":1}`)
srv := fakeGitHubReleaseWithManifest(t, "v2.0.0", manifest, []byte("not a signature"))
u := updater.NewUpdater("1.0.0", "", "test", "repo")
u.SetBaseURL(srv.URL)
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
rr := httptest.NewRecorder()
buildClientUpdateRouter(u).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (unverifiable manifest must not be advertised); body: %s", rr.Code, rr.Body.String())
}
}