mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-14 20:48:17 +08:00
379 lines
12 KiB
Go
379 lines
12 KiB
Go
package control
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
)
|
|
|
|
type rwStream struct {
|
|
in *bytes.Reader
|
|
out bytes.Buffer
|
|
}
|
|
|
|
func newStream(request string) *rwStream {
|
|
return &rwStream{in: bytes.NewReader([]byte(request))}
|
|
}
|
|
|
|
func (s *rwStream) Read(p []byte) (int, error) { return s.in.Read(p) }
|
|
func (s *rwStream) Write(p []byte) (int, error) { return s.out.Write(p) }
|
|
|
|
func (s *rwStream) response(t *testing.T) Response {
|
|
t.Helper()
|
|
frame := bytes.TrimRight(s.out.Bytes(), "\n")
|
|
if len(frame) == 0 {
|
|
t.Fatal("no response was written")
|
|
}
|
|
var decoded Response
|
|
if err := json.Unmarshal(frame, &decoded); err != nil {
|
|
t.Fatalf("response does not parse: %v (%q)", err, frame)
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func registryWith(t *testing.T, name string, handler Handler) *Registry {
|
|
t.Helper()
|
|
registry := NewRegistry()
|
|
registry.Register(name, handler)
|
|
return registry
|
|
}
|
|
|
|
func TestServeConnAnswersOneRequest(t *testing.T) {
|
|
registry := registryWith(t, "version.get",
|
|
func(context.Context, json.RawMessage) (any, error) {
|
|
return map[string]string{"version": "2.0.0rc1"}, nil
|
|
})
|
|
stream := newStream(`{"rpc_version":1,"request_id":"req-7","method":"version.get"}` + "\n")
|
|
|
|
if err := ServeConn(t.Context(), registry, stream); err != nil {
|
|
t.Fatalf("ServeConn: %v", err)
|
|
}
|
|
response := stream.response(t)
|
|
if !response.OK || response.RequestID != "req-7" {
|
|
t.Fatalf("response = %+v", response)
|
|
}
|
|
if response.RPCVersion != Version {
|
|
t.Fatalf("rpc_version = %d", response.RPCVersion)
|
|
}
|
|
if response.Error != nil {
|
|
t.Fatalf("error = %+v on a success", response.Error)
|
|
}
|
|
}
|
|
|
|
// The request id is echoed so a caller with several in flight can match the
|
|
// reply to what it asked.
|
|
func TestServeConnEchoesTheRequestID(t *testing.T) {
|
|
registry := registryWith(t, "version.get",
|
|
func(context.Context, json.RawMessage) (any, error) { return nil, nil })
|
|
stream := newStream(`{"rpc_version":1,"request_id":"click-abc-123","method":"version.get"}` + "\n")
|
|
|
|
if err := ServeConn(t.Context(), registry, stream); err != nil {
|
|
t.Fatalf("ServeConn: %v", err)
|
|
}
|
|
if got := stream.response(t).RequestID; got != "click-abc-123" {
|
|
t.Fatalf("request_id = %q", got)
|
|
}
|
|
}
|
|
|
|
// A malformed request still gets an answer: the peer is waiting for exactly one
|
|
// line, and dropping the connection would leave it to time out knowing nothing.
|
|
func TestServeConnAnswersEvenWhenTheRequestCannotBeParsed(t *testing.T) {
|
|
registry := NewRegistry()
|
|
stream := newStream("not json at all\n")
|
|
|
|
if err := ServeConn(t.Context(), registry, stream); err != nil {
|
|
t.Fatalf("ServeConn: %v", err)
|
|
}
|
|
response := stream.response(t)
|
|
if response.OK {
|
|
t.Fatal("a malformed request was answered with a success")
|
|
}
|
|
if response.Error.Code != string(domain.CodeInvalidArgument) {
|
|
t.Fatalf("code = %q", response.Error.Code)
|
|
}
|
|
}
|
|
|
|
// A peer that connects and leaves gets no reply and produces no error: there is
|
|
// nobody to tell.
|
|
func TestServeConnIgnoresACleanDisconnect(t *testing.T) {
|
|
stream := newStream("")
|
|
if err := ServeConn(t.Context(), NewRegistry(), stream); err != nil {
|
|
t.Fatalf("ServeConn: %v", err)
|
|
}
|
|
if stream.out.Len() != 0 {
|
|
t.Fatalf("wrote %q to a peer that had gone", stream.out.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestDispatchDistinguishesUnknownFromUnimplemented(t *testing.T) {
|
|
registry := NewRegistry()
|
|
|
|
unknown := registry.Dispatch(t.Context(),
|
|
Request{RPCVersion: Version, RequestID: "r", Method: "config.destroy"})
|
|
if unknown.Error.Code != string(domain.CodeNotFound) {
|
|
t.Fatalf("undeclared method -> %q, want NotFound", unknown.Error.Code)
|
|
}
|
|
|
|
// Declared but not bound in this build. Telling the caller it does not
|
|
// exist would be wrong: it does, and a later build will serve it.
|
|
declared := registry.Dispatch(t.Context(),
|
|
Request{RPCVersion: Version, RequestID: "r", Method: "update.start"})
|
|
if declared.Error.Code != string(domain.CodeUnsupportedCapability) {
|
|
t.Fatalf("unbound method -> %q, want UnsupportedCapability", declared.Error.Code)
|
|
}
|
|
}
|
|
|
|
// Only catalogued names can be bound. A registry assembled from whatever
|
|
// handlers happened to register would make the wire surface depend on
|
|
// initialisation order.
|
|
func TestRegisterRefusesUndeclaredAndDuplicateNames(t *testing.T) {
|
|
noop := func(context.Context, json.RawMessage) (any, error) { return nil, nil }
|
|
|
|
for _, name := range []string{"config.destroy", "", "shell.exec", "status"} {
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("registering %q was allowed", name)
|
|
}
|
|
}()
|
|
NewRegistry().Register(name, noop)
|
|
}()
|
|
}
|
|
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Error("registering the same method twice was allowed")
|
|
}
|
|
}()
|
|
registry := NewRegistry()
|
|
registry.Register("status.get", noop)
|
|
registry.Register("status.get", noop)
|
|
}()
|
|
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Error("a nil handler was accepted")
|
|
}
|
|
}()
|
|
NewRegistry().Register("status.get", nil)
|
|
}()
|
|
}
|
|
|
|
// Missing() lets startup refuse to serve a half-wired protocol rather than
|
|
// return NotFound for something the catalogue says exists.
|
|
func TestRegistryReportsUnboundMethods(t *testing.T) {
|
|
registry := NewRegistry()
|
|
if len(registry.Missing()) != len(MethodNames()) {
|
|
t.Fatalf("an empty registry reported %d missing, want %d",
|
|
len(registry.Missing()), len(MethodNames()))
|
|
}
|
|
|
|
registry.Register("status.get", func(context.Context, json.RawMessage) (any, error) {
|
|
return nil, nil
|
|
})
|
|
if slices.Contains(registry.Missing(), "status.get") {
|
|
t.Fatal("a bound method was still reported missing")
|
|
}
|
|
}
|
|
|
|
type sampleParams struct {
|
|
AccountID string `json:"account_id"`
|
|
Force bool `json:"force"`
|
|
}
|
|
|
|
// Unknown parameters are refused. Otherwise a caller could attach a parameter
|
|
// one method honours to another that silently ignores it.
|
|
func TestDecodeParamsRefusesUnknownFields(t *testing.T) {
|
|
var params sampleParams
|
|
if err := DecodeParams(json.RawMessage(`{"account_id":"c1","force":true}`), ¶ms); err != nil {
|
|
t.Fatalf("valid params rejected: %v", err)
|
|
}
|
|
if params.AccountID != "c1" || !params.Force {
|
|
t.Fatalf("params = %+v", params)
|
|
}
|
|
|
|
for _, raw := range []string{
|
|
`{"account_id":"c1","run_as":"root"}`,
|
|
`{"account_id":123}`,
|
|
`{"account_id":"c1"} {"account_id":"c2"}`,
|
|
`[1,2,3]`,
|
|
} {
|
|
var target sampleParams
|
|
if err := DecodeParams(json.RawMessage(raw), &target); err == nil {
|
|
t.Errorf("accepted %s", raw)
|
|
}
|
|
}
|
|
|
|
// Absent parameters are not an error: a read method may take none.
|
|
var empty sampleParams
|
|
if err := DecodeParams(nil, &empty); err != nil {
|
|
t.Errorf("absent params rejected: %v", err)
|
|
}
|
|
if err := DecodeParams(json.RawMessage(`null`), &empty); err != nil {
|
|
t.Errorf("null params rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// A handler's error reaches the caller as a stable code and a Chinese message,
|
|
// with the internal cause left behind.
|
|
func TestHandlerErrorsBecomeTheWireEnvelope(t *testing.T) {
|
|
registry := registryWith(t, "config.apply",
|
|
func(context.Context, json.RawMessage) (any, error) {
|
|
return nil, domain.FieldErrorf(domain.CodeConflict, "revision",
|
|
"配置已被其他地方修改,请重新加载后再保存")
|
|
})
|
|
|
|
response := registry.Dispatch(t.Context(),
|
|
Request{RPCVersion: Version, RequestID: "r", Method: "config.apply"})
|
|
|
|
if response.OK {
|
|
t.Fatal("a failing handler produced a success")
|
|
}
|
|
if string(response.Result) != "null" {
|
|
t.Fatalf("result = %s, want null so no half-answer can be read", response.Result)
|
|
}
|
|
if response.Error.Code != string(domain.CodeConflict) {
|
|
t.Fatalf("code = %q", response.Error.Code)
|
|
}
|
|
if !strings.Contains(response.Error.Message, "重新加载") {
|
|
t.Fatalf("message = %q, want the Chinese guidance", response.Error.Message)
|
|
}
|
|
if response.Error.Details["field"] != "revision" {
|
|
t.Fatalf("details = %v", response.Error.Details)
|
|
}
|
|
// A conflict needs the caller to re-read first, so retrying the same
|
|
// request unchanged would just fail again.
|
|
if response.Error.Retryable {
|
|
t.Fatal("Conflict was marked retryable")
|
|
}
|
|
}
|
|
|
|
func TestRetryabilityComesFromTheCodeNotTheTransport(t *testing.T) {
|
|
retryable := []domain.ErrorCode{
|
|
domain.CodeBusy, domain.CodeDeadlineExceeded, domain.CodeServiceStopped,
|
|
domain.CodeBindingUnavailable, domain.CodeBindingChanged,
|
|
domain.CodeDNSFailure, domain.CodeTransportFailure, domain.CodeTLSFailure,
|
|
}
|
|
permanent := []domain.ErrorCode{
|
|
domain.CodeInvalidArgument, domain.CodeInvalidConfig, domain.CodeConflict,
|
|
domain.CodeNotFound, domain.CodeCancelled, domain.CodeAuthRejected,
|
|
domain.CodeProtocolInvalid, domain.CodeOnlineIdentityMismatch,
|
|
domain.CodeRecoveryRequired, domain.CodeUnsupportedCapability,
|
|
domain.CodeChecksumMismatch, domain.CodePackageIncompatible,
|
|
domain.CodeInstallFailed, domain.CodePortalHTMLResponse,
|
|
domain.CodeInternal,
|
|
}
|
|
for _, code := range retryable {
|
|
if !Retryable(code) {
|
|
t.Errorf("%s should be retryable", code)
|
|
}
|
|
}
|
|
for _, code := range permanent {
|
|
if Retryable(code) {
|
|
t.Errorf("%s should not be retryable", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Details is a whitelist, so no call site anywhere can leak a secret by
|
|
// attaching it as context.
|
|
func TestErrorDetailsAreWhitelisted(t *testing.T) {
|
|
payload := &ErrorPayload{Details: sanitizeDetails(map[string]string{
|
|
"field": "campus_accounts[0].password",
|
|
"password": "hunter2",
|
|
"base_url": "http://user:pass@gateway/",
|
|
"mac": "aa:bb:cc:dd:ee:ff",
|
|
"action_id": "a1",
|
|
"stacktrace": "goroutine 1 ...",
|
|
})}
|
|
|
|
if payload.Details["field"] != "campus_accounts[0].password" {
|
|
t.Fatal("a whitelisted key was dropped")
|
|
}
|
|
if payload.Details["action_id"] != "a1" {
|
|
t.Fatal("action_id was dropped")
|
|
}
|
|
for _, forbidden := range []string{"password", "base_url", "mac", "stacktrace"} {
|
|
if _, present := payload.Details[forbidden]; present {
|
|
t.Errorf("%q survived the whitelist", forbidden)
|
|
}
|
|
}
|
|
|
|
// Nothing allowed through means no map, not an empty one, so a client never
|
|
// has to tell "no diagnostics" from "an empty bag of diagnostics".
|
|
if got := sanitizeDetails(map[string]string{"password": "hunter2"}); got != nil {
|
|
t.Errorf("sanitizeDetails = %v, want nil when nothing is on the whitelist", got)
|
|
}
|
|
}
|
|
|
|
// The wrapped cause is for the daemon's own log, not for a browser.
|
|
func TestWireErrorDoesNotCarryTheInternalCause(t *testing.T) {
|
|
err := domain.Errorf(domain.CodeTransportFailure, "无法连接认证服务器").
|
|
Wrap(errNetwork{})
|
|
payload := NewErrorPayload(err)
|
|
|
|
if strings.Contains(payload.Message, "10.0.0.2") {
|
|
t.Fatalf("message leaked the cause: %q", payload.Message)
|
|
}
|
|
if !payload.Retryable {
|
|
t.Error("a transport failure should be retryable")
|
|
}
|
|
}
|
|
|
|
type errNetwork struct{}
|
|
|
|
func (errNetwork) Error() string { return "dial tcp 10.0.0.2:80: connection refused" }
|
|
|
|
// The catalogue is the contract. Losing an entry would quietly remove a method
|
|
// the UI or CLI depends on.
|
|
func TestCatalogueCoversTheDeclaredMethodFamilies(t *testing.T) {
|
|
names := MethodNames()
|
|
for _, required := range []string{
|
|
"status.get", "schema.get", "capabilities.get", "version.get",
|
|
"config.get", "config.validate", "config.apply", "config.export", "config.import",
|
|
"campus.get", "campus.upsert", "campus.remove", "campus.set_default",
|
|
"hotspot.get", "hotspot.upsert", "hotspot.remove", "hotspot.set_default",
|
|
"action.submit", "action.get", "action.cancel",
|
|
"presets.list", "presets.refresh", "user_presets.get", "user_presets.set",
|
|
"detect.environment", "detect.acid", "detect.operators",
|
|
"detect.identity", "detect.verify",
|
|
"setup_wifi.start", "setup_wifi.status", "setup_wifi.cancel",
|
|
"setup_wifi.commit", "setup_wifi.account",
|
|
"log.tail", "log.download", "log.clear",
|
|
"update.check", "update.start", "update.status",
|
|
"schools.list", "schools.inspect", "school.command",
|
|
} {
|
|
if !slices.Contains(names, required) {
|
|
t.Errorf("catalogue is missing %q", required)
|
|
}
|
|
}
|
|
if len(names) != 43 {
|
|
t.Errorf("catalogue has %d methods, expected the 43 from the contract (D75 backups); "+
|
|
"adding one needs a decision record", len(names))
|
|
}
|
|
}
|
|
|
|
// Read methods are what the page polls. One that mutated or probed would turn
|
|
// an idle browser tab into traffic on the campus gateway.
|
|
func TestPolledMethodsAreDeclaredRead(t *testing.T) {
|
|
for _, name := range []string{
|
|
"status.get", "log.tail", "update.status", "action.get", "setup_wifi.status",
|
|
} {
|
|
method, ok := Lookup(name)
|
|
if !ok {
|
|
t.Fatalf("%q is not in the catalogue", name)
|
|
}
|
|
if method.Kind != KindRead {
|
|
t.Errorf("%q is %q; the UI polls it, so it must be read-only",
|
|
name, method.Kind)
|
|
}
|
|
}
|
|
}
|