Add `ninja plugin gallery list|add|remove|reorder|set-featured` for author-side curation of a plugin/theme's registry screenshot gallery, feeding the browse/detail surfaces shipped in Phases 4-5. - Re-vendor plugin_registry.proto from orchestrator (adds PluginGalleryService) and regenerate the connect client. - Wire the Gallery client into orchclient.Client. - Target plugin defaults from plugin.mod with --scope/--name overrides; device-flow auth via resolveClient. list is a public read; add/remove/ reorder/set-featured are RoleUser (scope/account membership enforced server-side). add derives content_type from the extension against the png/jpg/jpeg/webp allowlist and rejects >8MiB client-side. Scope handling trims a leading @ client-side because ListScreenshots does not trim server-side (GetPlugin does). Live-verified against the dev orchestrator; go build + go test ./... + check-safety all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
5.7 KiB
Go
192 lines
5.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
core "git.dev.alexdunmow.com/block/core/plugin"
|
|
"git.dev.alexdunmow.com/block/cli/internal/creds"
|
|
)
|
|
|
|
func TestImageContentType(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{path: "preview.png", want: "image/png"},
|
|
{path: "shot.jpg", want: "image/jpeg"},
|
|
{path: "shot.jpeg", want: "image/jpeg"},
|
|
{path: "shot.webp", want: "image/webp"},
|
|
{path: "SHOT.PNG", want: "image/png"}, // case-insensitive
|
|
{path: "dir/nested/x.jpg", want: "image/jpeg"},
|
|
{path: "shot.gif", wantErr: true},
|
|
{path: "shot.svg", wantErr: true},
|
|
{path: "noext", wantErr: true},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := imageContentType(c.path)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Errorf("imageContentType(%q) = %q, want error", c.path, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("imageContentType(%q) err: %v", c.path, err)
|
|
continue
|
|
}
|
|
if got != c.want {
|
|
t.Errorf("imageContentType(%q) = %q, want %q", c.path, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// galleryTestCmd builds a command carrying the --scope/--name flags exactly as
|
|
// the gallery parent registers them, so resolveGalleryTarget can read them.
|
|
func galleryTestCmd(scope, name string) *cobra.Command {
|
|
c := &cobra.Command{Use: "gallery"}
|
|
c.Flags().String("scope", "", "")
|
|
c.Flags().String("name", "", "")
|
|
if scope != "" {
|
|
_ = c.Flags().Set("scope", scope)
|
|
}
|
|
if name != "" {
|
|
_ = c.Flags().Set("name", name)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func writeGalleryMod(t *testing.T, dir, scope, name string, private bool) {
|
|
t.Helper()
|
|
m := &core.ModFile{Plugin: core.ModPlugin{
|
|
Name: name,
|
|
Scope: scope,
|
|
Version: "0.1.0",
|
|
Private: private,
|
|
}}
|
|
if err := writeMod(filepath.Join(dir, "plugin.mod"), m); err != nil {
|
|
t.Fatalf("writeMod: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_FromMod(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
writeGalleryMod(t, dir, "@themes", "gotham", false)
|
|
|
|
got, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGalleryTarget: %v", err)
|
|
}
|
|
// Leading @ must be trimmed (ListScreenshots does not trim server-side).
|
|
if got.scopeSlug != "themes" || got.name != "gotham" || got.private {
|
|
t.Errorf("got %+v, want scopeSlug=themes name=gotham private=false", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_FlagOverride(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
writeGalleryMod(t, dir, "@themes", "gotham", false)
|
|
|
|
got, err := resolveGalleryTarget(galleryTestCmd("acme", "widget"), creds.HostCreds{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGalleryTarget: %v", err)
|
|
}
|
|
if got.scopeSlug != "acme" || got.name != "widget" {
|
|
t.Errorf("got %+v, want scopeSlug=acme name=widget", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_NoModNeedsFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Chdir(dir) // no plugin.mod written
|
|
|
|
// Flags supply both coordinates → resolves without a plugin.mod.
|
|
got, err := resolveGalleryTarget(galleryTestCmd("acme", "widget"), creds.HostCreds{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGalleryTarget with flags: %v", err)
|
|
}
|
|
if got.scopeSlug != "acme" || got.name != "widget" {
|
|
t.Errorf("got %+v, want scopeSlug=acme name=widget", got)
|
|
}
|
|
|
|
// No plugin.mod and no flags → an actionable error.
|
|
if _, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{}); err == nil {
|
|
t.Error("expected error with no plugin.mod and no flags")
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_MissingScope(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
|
|
// Name but no scope (public) → error naming the missing scope.
|
|
_, err := resolveGalleryTarget(galleryTestCmd("", "widget"), creds.HostCreds{})
|
|
if err == nil {
|
|
t.Fatal("expected error for name without scope")
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_PrivateFromMod(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
writeGalleryMod(t, dir, "", "widget", true)
|
|
|
|
// Private plugin without an active account → error.
|
|
if _, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{}); err == nil {
|
|
t.Error("expected error: private gallery needs an active account")
|
|
}
|
|
|
|
// With an active account, the scope resolves to the @private namespace.
|
|
got, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{ActiveAccountID: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGalleryTarget private: %v", err)
|
|
}
|
|
if !got.private || got.scopeSlug != core.PrivateScopeSlug || got.accountID != "acct-1" {
|
|
t.Errorf("got %+v, want private=true scopeSlug=%s accountID=acct-1", got, core.PrivateScopeSlug)
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_PrivateScopeNameImpliesPrivate(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
|
|
// Naming the private scope directly flips private on (and still needs an account).
|
|
got, err := resolveGalleryTarget(galleryTestCmd("@"+core.PrivateScopeSlug, "widget"), creds.HostCreds{ActiveAccountID: "acct-1"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGalleryTarget: %v", err)
|
|
}
|
|
if !got.private || got.scopeSlug != core.PrivateScopeSlug {
|
|
t.Errorf("got %+v, want private=true scopeSlug=%s", got, core.PrivateScopeSlug)
|
|
}
|
|
}
|
|
|
|
func TestResolveGalleryTarget_ReadFileHelper(t *testing.T) {
|
|
// Guard that a valid image on disk round-trips through the same os.ReadFile
|
|
// path `add` uses (content-type derived from the extension).
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "preview.png")
|
|
if err := os.WriteFile(path, []byte("\x89PNG\r\n\x1a\nfake"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ct, err := imageContentType(path)
|
|
if err != nil {
|
|
t.Fatalf("imageContentType: %v", err)
|
|
}
|
|
if ct != "image/png" {
|
|
t.Errorf("content type = %q, want image/png", ct)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if len(data) == 0 || len(data) > maxScreenshotBytes {
|
|
t.Errorf("unexpected size %d", len(data))
|
|
}
|
|
}
|