mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.
Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
a concurrent NewAppMetrics() could observe a swapped provider but read
stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
uint64 values that exceed math.MaxInt64 fall back to a STRING attr
rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
histogram recording, shutdown idempotency, AppMetrics rebind, and
the uint64 overflow fallback.
Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
pre-instantiated; activateWithRuntime compiles + instantiates each
plugin module under that runtime; platformDeactivate closes per-
plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
a parameter to activateWithRuntime; the call no longer re-reads the
field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
present; missing/broken exports return a user-facing diagnostic
instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
re-enable after disable (verifies the leak fix), close-twice safety,
invalid wasm rejection, and DispatchCommand with a missing export.
Fixture is a 41-byte embedded add.wasm; no external asset required.
Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
Content-Type is not application/zip|x-zip-compressed|octet-stream
(415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
/ size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
content-type rejection, magic rejection, happy path, lifecycle 503,
invalid id, and isZipContentType / hasZipMagic helpers.
Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
glob to include src/**/*.test.tsx so Badge.test.tsx is actually
discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
instead of "*", and exposes a destroy() that detaches the message
listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
"solid-js/web" (the latter does not re-export it), unblocking
npx tsc --noEmit.
Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
-tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.
PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.
https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
242 lines
7.5 KiB
Go
242 lines
7.5 KiB
Go
// Phase C Step 9 — PluginAdminHandler tests.
|
|
//
|
|
// The handler is covered at the HTTP boundary so the fixtures do not depend
|
|
// on the Wazero runtime. A nil Registry exercises the "plugin runtime
|
|
// disabled" branch; a real Registry wired against a MemStore exercises the
|
|
// happy path.
|
|
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/plugin"
|
|
"github.com/owncord/server/store"
|
|
)
|
|
|
|
func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) {
|
|
h := NewPluginAdminHandler(nil, nil)
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want 200", rec.Code)
|
|
}
|
|
if strings.TrimSpace(rec.Body.String()) != "[]" {
|
|
t.Fatalf("expected empty JSON array, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginsHandlerInstallRejectsWhenRegistryNil(t *testing.T) {
|
|
h := NewPluginAdminHandler(nil, nil)
|
|
body, contentType := buildZipUpload(t, validPluginZip(t))
|
|
req := httptest.NewRequest("POST", "/install", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status: got %d, want 503", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPluginsHandlerInstallRejectsNonZipContentType(t *testing.T) {
|
|
reg := newTestPluginRegistry(t)
|
|
h := NewPluginAdminHandler(reg, nil)
|
|
|
|
// Build a multipart body whose file part is labelled as text/plain.
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
partHeader := make(map[string][]string)
|
|
partHeader["Content-Disposition"] = []string{`form-data; name="plugin"; filename="evil.txt"`}
|
|
partHeader["Content-Type"] = []string{"text/plain"}
|
|
part, err := mw.CreatePart(partHeader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(validPluginZip(t)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = mw.Close()
|
|
|
|
req := httptest.NewRequest("POST", "/install", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnsupportedMediaType {
|
|
t.Fatalf("status: got %d, want 415; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginsHandlerInstallRejectsNonZipMagic(t *testing.T) {
|
|
reg := newTestPluginRegistry(t)
|
|
h := NewPluginAdminHandler(reg, nil)
|
|
|
|
body, contentType := buildZipUpload(t, []byte("this is definitely not a zip"))
|
|
req := httptest.NewRequest("POST", "/install", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginsHandlerInstallHappyPath(t *testing.T) {
|
|
reg := newTestPluginRegistry(t)
|
|
mem := store.NewMemStore()
|
|
// Wire the store into the handler so /list can show the new row. The
|
|
// registry already writes via its own PluginStore.
|
|
h := NewPluginAdminHandler(reg, mem)
|
|
body, contentType := buildZipUpload(t, validPluginZip(t))
|
|
req := httptest.NewRequest("POST", "/install", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status: got %d, want 201; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "hello") {
|
|
t.Fatalf("expected plugin name in response, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginsHandlerEnableDisableUninstallReturn503WhenRegistryNil(t *testing.T) {
|
|
h := NewPluginAdminHandler(nil, nil)
|
|
for _, tc := range []struct{ method, path string }{
|
|
{"POST", "/1/enable"},
|
|
{"POST", "/1/disable"},
|
|
{"DELETE", "/1"},
|
|
} {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("%s %s status: got %d, want 503", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPluginsHandlerLifecycleInvalidID(t *testing.T) {
|
|
reg := newTestPluginRegistry(t)
|
|
h := NewPluginAdminHandler(reg, nil)
|
|
req := httptest.NewRequest("POST", "/not-an-int/enable", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestIsZipContentType(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"application/zip": true,
|
|
"application/zip; charset=binary": true,
|
|
"APPLICATION/ZIP": true,
|
|
"application/x-zip-compressed": true,
|
|
"application/octet-stream": true,
|
|
"text/plain": false,
|
|
"image/png": false,
|
|
"": false,
|
|
"application/json; charset=utf-8": false,
|
|
}
|
|
for ct, want := range cases {
|
|
if got := isZipContentType(ct); got != want {
|
|
t.Errorf("isZipContentType(%q) = %v, want %v", ct, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHasZipMagic(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"PK\x03\x04rest": true,
|
|
"PK\x05\x06": true,
|
|
"PK\x07\x08rest": false, // spanned-archive signature; not accepted here
|
|
"not a zip": false,
|
|
"": false,
|
|
"PK": false,
|
|
}
|
|
for body, want := range cases {
|
|
if got := hasZipMagic([]byte(body)); got != want {
|
|
t.Errorf("hasZipMagic(%q) = %v, want %v", body, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
func newTestPluginRegistry(t *testing.T) *plugin.Registry {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
mem := store.NewMemStore()
|
|
reg, err := plugin.NewRegistry(plugin.Config{
|
|
Directory: filepath.Join(dir, "plugins"),
|
|
Store: mem,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("plugin.NewRegistry: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = reg.Close(context.Background()) })
|
|
return reg
|
|
}
|
|
|
|
// validPluginZip returns a minimal but structurally valid plugin package:
|
|
// a plugin.json manifest at the root plus a near-empty hello.wasm that is
|
|
// large enough to pass the entrypoint stat but small enough to fly well
|
|
// under the zip-bomb cap.
|
|
func validPluginZip(t *testing.T) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
mj, err := zw.Create("plugin.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := mj.Write([]byte(`{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w, err := zw.Create("hello.wasm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Minimal "placeholder" wasm magic bytes. Default build does not attempt
|
|
// to compile the module, so any bytes with the wasm magic suffice for
|
|
// InstallFromZip's on-disk validation.
|
|
if _, err := w.Write([]byte("\x00asm\x01\x00\x00\x00")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// buildZipUpload wraps bodyBytes in a multipart form with a single "plugin"
|
|
// file part labelled as application/zip.
|
|
func buildZipUpload(t *testing.T, bodyBytes []byte) (io.Reader, string) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
partHeader := make(map[string][]string)
|
|
partHeader["Content-Disposition"] = []string{`form-data; name="plugin"; filename="hello.zip"`}
|
|
partHeader["Content-Type"] = []string{"application/zip"}
|
|
part, err := mw.CreatePart(partHeader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(bodyBytes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &buf, mw.FormDataContentType()
|
|
}
|