265 lines
11 KiB
Go
265 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/repository"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestModelChannelAllowLocalDefaultsFalseAfterAutoMigrate(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.ModelChannel{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
channel := model.ModelChannel{ID: "legacy-channel", Name: "Legacy", BaseURL: "https://example.com", Scope: model.ChannelScopeSystem, Enabled: true}
|
|
if err := db.Omit("AllowLocalChannel").Create(&channel).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stored model.ModelChannel
|
|
if err := db.First(&stored, "id = ?", channel.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.AllowLocalChannel {
|
|
t.Fatal("legacy row must migrate with allowLocalChannel=false")
|
|
}
|
|
}
|
|
|
|
func TestSystemChannelAllowLocalSaveRequiresCapabilityAndPatchPreservesOmission(t *testing.T) {
|
|
requested := true
|
|
closed := &Service{}
|
|
_, err := closed.channelFromRequest(ChannelRequest{Name: "Local", BaseURL: "http://127.0.0.1:8000", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem})
|
|
if err == nil {
|
|
t.Fatal("server capability=false must reject saving allowLocalChannel=true")
|
|
}
|
|
if _, err := closed.channelFromRequest(ChannelRequest{Name: "Forged", BaseURL: "https://8.8.8.8", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem}); err == nil {
|
|
t.Fatal("server capability=false must reject persisting a forged allowLocalChannel=true flag even for a public Base URL")
|
|
}
|
|
|
|
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
|
channel, err := open.channelFromRequest(ChannelRequest{Name: "Local", BaseURL: "http://127.0.0.1:8000", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem})
|
|
if err != nil {
|
|
t.Fatalf("channelFromRequest() error = %v", err)
|
|
}
|
|
if !channel.AllowLocalChannel || channel.BaseURL != "http://127.0.0.1:8000" {
|
|
t.Fatalf("stored channel = %#v", channel)
|
|
}
|
|
|
|
merged := mergeChannelRequest(ChannelRequest{Enabled: boolPtr(false)}, channel)
|
|
if merged.AllowLocalChannel == nil || !*merged.AllowLocalChannel {
|
|
t.Fatal("PATCH omitting allowLocalChannel must preserve stored true")
|
|
}
|
|
}
|
|
|
|
func TestPublicSystemChannelHidesLocalAddressAndLocalFlagFromOrdinaryUsers(t *testing.T) {
|
|
channel := model.ModelChannel{
|
|
ID: "system-local", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Desktop Local",
|
|
BaseURL: "http://127.0.0.1:8000", APIKey: "secret", AllowLocalChannel: true,
|
|
}
|
|
admin := publicChannel(channel, true, nil)
|
|
if admin.BaseURL != channel.BaseURL || !admin.AllowLocalChannel {
|
|
t.Fatalf("admin channel = %#v", admin)
|
|
}
|
|
user := publicChannel(channel, false, nil)
|
|
if user.BaseURL != "/api/ai/system/system-local" || user.APIKey != "system" || user.AllowLocalChannel {
|
|
t.Fatalf("ordinary user channel leaked local configuration: %#v", user)
|
|
}
|
|
}
|
|
|
|
func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"local-model"}]}`))
|
|
}))
|
|
defer upstream.Close()
|
|
actor := &model.User{ID: "user-1"}
|
|
input := ChannelModelsRequest{BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true}
|
|
|
|
closed := &Service{}
|
|
if _, err := closed.FetchChannelModelCatalog(context.Background(), actor, input); err == nil {
|
|
t.Fatal("model catalog must reject forged allowLocalChannel=true when server capability=false")
|
|
}
|
|
|
|
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
|
catalog, err := open.FetchChannelModelCatalog(context.Background(), actor, input)
|
|
if err != nil {
|
|
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
|
|
}
|
|
if len(catalog) != 1 || catalog[0].ID != "local-model" {
|
|
t.Fatalf("catalog = %#v", catalog)
|
|
}
|
|
}
|
|
|
|
func TestCustomModelCatalogPreservesPublicCapabilityMetadataWithoutCompatibilityIDs(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"data":[
|
|
{"id":"image-fast","display_name":"Image Fast","model_type":"image"},
|
|
{"id":"image-quality","display_name":"Image Quality","model_type":"image"},
|
|
{"id":"omni","capability_id":"omni-flash","display_name":"Omni Flash","model_type":"video","supports_images":false,"min_images":0,"max_images":0,"default_parameters":{"aspect_ratio":"16:9","duration_seconds":"10"},"options":{"aspect_ratio":[{"value":"16:9"},{"value":"9:16"}],"duration_seconds":[{"value":"8"},{"value":"10"}]},"compatibility_map":[{"parameters":{"aspect_ratio":"9:16","duration_seconds":"10"},"model_id":"omni_portrait_10s"}]},
|
|
{"id":"veo-lite","display_name":"Veo Lite","model_type":"video"},
|
|
{"id":"veo-fast","display_name":"Veo Fast","model_type":"video"},
|
|
{"id":"veo-quality","display_name":"Veo Quality","model_type":"video"}
|
|
]}`))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
|
catalog, err := svc.FetchChannelModelCatalog(context.Background(), &model.User{ID: "user-1"}, ChannelModelsRequest{
|
|
BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
|
|
}
|
|
if len(catalog) != 6 {
|
|
t.Fatalf("catalog length = %d; want 6", len(catalog))
|
|
}
|
|
var omni *ChannelModelCatalogItem
|
|
for index := range catalog {
|
|
if catalog[index].ID == "omni" {
|
|
omni = &catalog[index]
|
|
break
|
|
}
|
|
}
|
|
if omni == nil {
|
|
t.Fatal("catalog is missing public omni capability")
|
|
}
|
|
if omni.DisplayName != "Omni Flash" || omni.ModelType != "video" {
|
|
t.Fatalf("omni metadata = %#v", omni)
|
|
}
|
|
if omni.DefaultParameters.AspectRatio != "16:9" || omni.DefaultParameters.DurationSeconds != "10" {
|
|
t.Fatalf("omni defaults = %#v", omni.DefaultParameters)
|
|
}
|
|
if got := optionValues(omni.Options.DurationSeconds); len(got) != 2 || got[0] != "8" || got[1] != "10" {
|
|
t.Fatalf("omni duration options = %#v", got)
|
|
}
|
|
encoded, err := json.Marshal(catalog)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(encoded), "omni_portrait_10s") || strings.Contains(string(encoded), "compatibility") {
|
|
t.Fatalf("ordinary catalog exposed compatibility IDs: %s", encoded)
|
|
}
|
|
}
|
|
|
|
func optionValues(options []ChannelModelCatalogOption) []string {
|
|
values := make([]string, 0, len(options))
|
|
for _, option := range options {
|
|
values = append(values, option.Value)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
channel := model.ModelChannel{
|
|
ID: "channel-local", UserID: "admin", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Local",
|
|
BaseURL: "http://127.0.0.1:8000", APIKey: "key", APIFormat: "openai", ModelsJSON: `["text-model"]`, AllowLocalChannel: true,
|
|
}
|
|
channelModel := model.ChannelModel{ID: "model-local", ChannelID: channel.ID, ModelKey: "text-model", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceConfigured: true}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&channelModel).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := repository.New(db)
|
|
open := &Service{repo: repo, runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
|
resolved, err := open.resolveProviderConfig(providerConfig{ChannelID: channel.ID, Model: "text-model"})
|
|
if err != nil {
|
|
t.Fatalf("desktop resolveProviderConfig() error = %v", err)
|
|
}
|
|
if !resolved.AllowLocalChannel {
|
|
t.Fatal("desktop worker must retain the effective local policy in resolved provider config")
|
|
}
|
|
encoded, err := json.Marshal(resolved)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var recovered providerConfig
|
|
if err := json.Unmarshal(encoded, &recovered); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
closedAfterRestart := &Service{repo: repo, runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: false}}
|
|
if _, err := closedAfterRestart.resolveProviderConfig(recovered); err == nil {
|
|
t.Fatal("restart with server capability=false must reject a DB/task payload that still carries allowLocalChannel=true")
|
|
}
|
|
}
|
|
|
|
func TestCustomTaskInputRecoveryReevaluatesLocalFlagAgainstServerCapability(t *testing.T) {
|
|
input, err := normalizeTaskInput(map[string]any{
|
|
"mode": "text",
|
|
"config": providerConfig{BaseURL: "http://127.0.0.1:8000", APIKey: "key", Model: "text-model", AllowLocalChannel: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded, err := json.Marshal(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var recovered canvasGenerationInput
|
|
if err := json.Unmarshal(encoded, &recovered); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !recovered.Config.AllowLocalChannel {
|
|
t.Fatal("task InputJSON recovery lost allowLocalChannel")
|
|
}
|
|
closed := &Service{}
|
|
if _, err := closed.resolveProviderConfig(recovered.Config); err == nil {
|
|
t.Fatal("recovered custom task must fail closed when server capability=false")
|
|
}
|
|
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
|
resolved, err := open.resolveProviderConfig(recovered.Config)
|
|
if err != nil || !resolved.AllowLocalChannel {
|
|
t.Fatalf("desktop recovered config = %#v, error = %v", resolved, err)
|
|
}
|
|
}
|
|
|
|
func TestSystemChannelOutboundClientUsesStoredLocalFlagAndCapability(t *testing.T) {
|
|
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer source.Close()
|
|
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
|
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
|
|
|
closed := &Service{}
|
|
if _, err := closed.ValidateChannelOutboundURL(source.URL, true, false); err == nil {
|
|
t.Fatal("stored allowLocalChannel=true must remain closed when server capability=false")
|
|
}
|
|
|
|
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
|
target, err := open.ValidateChannelOutboundURL(source.URL, true, false)
|
|
if err != nil {
|
|
t.Fatalf("ValidateChannelOutboundURL() error = %v", err)
|
|
}
|
|
request, _ := http.NewRequest(http.MethodGet, target.String(), nil)
|
|
response, err := open.OutboundHTTPClientForChannel(time.Second, target, true).Do(request)
|
|
if err != nil {
|
|
t.Fatalf("OutboundHTTPClientForChannel() error = %v", err)
|
|
}
|
|
_ = response.Body.Close()
|
|
}
|
|
|
|
func boolPtr(value bool) *bool { return &value }
|