feat: unify gateway settings into Connection screen and add WS auth key

Consolidate gateway port/API key management into a single Connection
screen that updates the server via config API, and separate WS auth
from gateway auth by adding api_key to WebSocketConfig.

- Remove GatewayConfig.Host (hardcode 127.0.0.1)
- Add WebSocketConfig.APIKey with constant-time auth check on WS upgrade
- Exclude gateway section from Backend Config schema UI
- Refactor Android App Settings → Connection (port + API key only)
- Fetch WS connection info dynamically from config API
- Remove wsPort/wsUrl from GatewaySettings and WebSocketClient.apiKey

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-28 03:39:49 +09:00
parent 7746cafe50
commit 38da89539e
14 changed files with 137 additions and 138 deletions

View file

@ -1,15 +1,22 @@
package io.clawdroid
import android.app.Application
import android.util.Log
import io.clawdroid.backend.api.GatewaySettingsStore
import io.clawdroid.backend.config.ConfigApiClient
import io.clawdroid.backend.config.configModule
import io.clawdroid.core.data.remote.WebSocketClient
import io.clawdroid.di.appModule
import io.clawdroid.receiver.NotificationHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import java.net.URLEncoder
class ClawDroidApp : Application() {
override fun onCreate() {
@ -23,16 +30,51 @@ class ClawDroidApp : Application() {
val koin = koinApp.koin
val settingsStore: GatewaySettingsStore = koin.get()
val wsClient: WebSocketClient = koin.get()
val configApiClient: ConfigApiClient = koin.get()
val scope: CoroutineScope = koin.get()
scope.launch {
settingsStore.settings.collect { s ->
if (wsClient.wsUrl != s.wsUrl || wsClient.apiKey != s.apiKey) {
// Only react when httpPort or apiKey actually change
settingsStore.settings
.map { it.httpPort to it.apiKey }
.distinctUntilChanged()
.collect {
// Fetch WS connection info from config API
val wsUrl = fetchWsUrl(configApiClient)
if (wsClient.wsUrl != wsUrl) {
wsClient.disconnect()
wsClient.wsUrl = s.wsUrl
wsClient.apiKey = s.apiKey
wsClient.wsUrl = wsUrl
wsClient.connect()
}
}
}
}
private suspend fun fetchWsUrl(configApiClient: ConfigApiClient): String {
return try {
val cfg = configApiClient.getConfig()
val ws = cfg["channels"]?.jsonObject?.get("websocket")?.jsonObject
if (ws != null) {
val host = ws["host"]?.jsonPrimitive?.content ?: "127.0.0.1"
val port = ws["port"]?.jsonPrimitive?.content ?: "18793"
val path = ws["path"]?.jsonPrimitive?.content ?: "/ws"
val wsApiKey = ws["api_key"]?.jsonPrimitive?.content ?: ""
buildString {
append("ws://$host:$port$path")
if (wsApiKey.isNotEmpty()) {
append("?api_key=${URLEncoder.encode(wsApiKey, "UTF-8")}")
}
}
} else {
DEFAULT_WS_URL
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch WS config from API, using defaults", e)
DEFAULT_WS_URL
}
}
companion object {
private const val TAG = "ClawDroidApp"
private const val DEFAULT_WS_URL = "ws://127.0.0.1:18793/ws"
}
}

View file

@ -78,11 +78,7 @@ val appModule = module {
val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also {
prefs.edit().putString("client_id", it).apply()
}
val gwSettings = get<GatewaySettingsStore>().settings.value
WebSocketClient(get(), get(), clientId).apply {
wsUrl = gwSettings.wsUrl
apiKey = gwSettings.apiKey
}
WebSocketClient(get(), get(), clientId)
}
// ImageFileStorage
@ -134,5 +130,5 @@ val appModule = module {
// ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AppSettingsViewModel(get()) }
viewModel { AppSettingsViewModel(get(), get()) }
}

View file

@ -59,7 +59,7 @@ fun AppSettingsScreen(
) {
val uiState by viewModel.uiState.collectAsState()
var apiKeyHidden by remember { mutableStateOf(true) }
val saveEnabled by remember { derivedStateOf { !uiState.hasErrors } }
val saveEnabled by remember { derivedStateOf { !uiState.hasErrors && !uiState.saving } }
Box(
modifier = Modifier
@ -92,7 +92,7 @@ fun AppSettingsScreen(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text("App Settings") },
title = { Text("Connection") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
@ -115,7 +115,7 @@ fun AppSettingsScreen(
),
modifier = Modifier.padding(end = 8.dp),
) {
Text("Save")
Text(if (uiState.saving) "Saving…" else "Save")
}
},
)
@ -135,10 +135,23 @@ fun AppSettingsScreen(
color = NeonCyan,
)
OutlinedTextField(
value = uiState.httpPort,
onValueChange = { viewModel.onHttpPortChange(it) },
label = { Text("Port", color = TextSecondary) },
placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
isError = uiState.httpPortError != null,
supportingText = uiState.httpPortError?.let { err -> { Text(err) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = appSettingsFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.apiKey,
onValueChange = { viewModel.onApiKeyChange(it) },
label = { Text("Gateway API Key", color = TextSecondary) },
label = { Text("API Key", color = TextSecondary) },
singleLine = true,
visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = {
@ -154,35 +167,17 @@ fun AppSettingsScreen(
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.wsPort,
onValueChange = { viewModel.onWsPortChange(it) },
label = { Text("Gateway WS Port", color = TextSecondary) },
placeholder = { Text("18793", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
isError = uiState.wsPortError != null,
supportingText = uiState.wsPortError?.let { err -> { Text(err) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = appSettingsFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.httpPort,
onValueChange = { viewModel.onHttpPortChange(it) },
label = { Text("Gateway HTTP Port", color = TextSecondary) },
placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
isError = uiState.httpPortError != null,
supportingText = uiState.httpPortError?.let { err -> { Text(err) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = appSettingsFieldColors(),
modifier = Modifier.fillMaxWidth(),
uiState.error?.let { error ->
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@Composable
private fun appSettingsFieldColors() = OutlinedTextFieldDefaults.colors(

View file

@ -4,20 +4,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.clawdroid.backend.api.GatewaySettings
import io.clawdroid.backend.api.GatewaySettingsStore
import io.clawdroid.backend.config.ConfigApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
data class AppSettingsUiState(
val apiKey: String = "",
val wsPort: String = "18793",
val httpPort: String = "18790",
val saving: Boolean = false,
val error: String? = null,
) {
val wsPortError: String? get() = portError(wsPort)
val httpPortError: String? get() = portError(httpPort)
val hasErrors: Boolean get() = wsPortError != null || httpPortError != null
val hasErrors: Boolean get() = httpPortError != null
}
private fun portError(value: String): String? {
@ -28,6 +31,7 @@ private fun portError(value: String): String? {
class AppSettingsViewModel(
private val settingsStore: GatewaySettingsStore,
private val configApiClient: ConfigApiClient,
) : ViewModel() {
private val _uiState = MutableStateFlow(AppSettingsUiState())
@ -37,43 +41,48 @@ class AppSettingsViewModel(
val current = settingsStore.settings.value
_uiState.value = AppSettingsUiState(
apiKey = current.apiKey,
wsPort = current.wsPort.toString(),
httpPort = current.httpPort.toString(),
)
}
fun onApiKeyChange(value: String) {
_uiState.update { it.copy(apiKey = value) }
}
fun onWsPortChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(wsPort = value) }
}
_uiState.update { it.copy(apiKey = value, error = null) }
}
fun onHttpPortChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(httpPort = value) }
_uiState.update { it.copy(httpPort = value, error = null) }
}
}
fun save(onComplete: () -> Unit) {
viewModelScope.launch {
val state = _uiState.value
if (state.hasErrors) return@launch
if (state.hasErrors || state.saving) return@launch
_uiState.update { it.copy(saving = true, error = null) }
val defaults = GatewaySettings()
fun validPort(raw: String, fallback: Int): Int {
val port = raw.toIntOrNull() ?: return fallback
return if (port in 1..65535) port else fallback
val newPort = state.httpPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: defaults.httpPort
val newKey = state.apiKey
// Send update via config API using current (old) connection settings
val payload = buildJsonObject {
put("gateway", buildJsonObject {
put("port", JsonPrimitive(newPort))
put("api_key", JsonPrimitive(newKey))
})
}
val settings = GatewaySettings(
wsPort = validPort(state.wsPort, defaults.wsPort),
httpPort = validPort(state.httpPort, defaults.httpPort),
apiKey = state.apiKey,
)
settingsStore.update(settings)
try {
configApiClient.saveConfig(payload)
// Persist new values locally after remote success
settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
_uiState.update { it.copy(saving = false) }
onComplete()
} catch (e: Exception) {
_uiState.update { it.copy(saving = false, error = e.message ?: "Save failed") }
}
}
}
}

View file

@ -21,7 +21,6 @@ class GatewaySettingsStoreImpl(
) : GatewaySettingsStore {
private object Keys {
val WS_PORT = intPreferencesKey("ws_port")
val HTTP_PORT = intPreferencesKey("http_port")
val API_KEY = stringPreferencesKey("api_key")
}
@ -29,7 +28,6 @@ class GatewaySettingsStoreImpl(
override val settings: StateFlow<GatewaySettings> =
context.gatewayDataStore.data.map { prefs ->
GatewaySettings(
wsPort = prefs[Keys.WS_PORT] ?: DEFAULT.wsPort,
httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort,
apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey,
)
@ -41,7 +39,6 @@ class GatewaySettingsStoreImpl(
override suspend fun update(settings: GatewaySettings) {
context.gatewayDataStore.edit { prefs ->
prefs[Keys.WS_PORT] = settings.wsPort
prefs[Keys.HTTP_PORT] = settings.httpPort
prefs[Keys.API_KEY] = settings.apiKey
}

View file

@ -1,10 +1,8 @@
package io.clawdroid.backend.api
data class GatewaySettings(
val wsPort: Int = 18793,
val httpPort: Int = 18790,
val apiKey: String = "",
) {
val wsUrl: String get() = "ws://127.0.0.1:$wsPort/ws"
val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort"
}

View file

@ -22,7 +22,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.URLEncoder
class WebSocketClient(
private val client: HttpClient,
@ -42,7 +42,6 @@ class WebSocketClient(
private val json = Json { ignoreUnknownKeys = true }
@Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws"
@Volatile var apiKey: String = ""
fun connect() {
if (connectJob?.isActive == true) return
@ -52,11 +51,8 @@ class WebSocketClient(
try {
_connectionState.value = ConnectionState.CONNECTING
val currentWsUrl = wsUrl
val currentApiKey = apiKey
val url = buildString {
append("$currentWsUrl?client_id=$clientId&client_type=$clientType")
if (currentApiKey.isNotEmpty()) append("&api_key=${URLEncoder.encode(currentApiKey, "UTF-8")}")
}
val separator = if ('?' in currentWsUrl) '&' else '?'
val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType"
client.webSocket(url) {
session = this
_connectionState.value = ConnectionState.CONNECTED

View file

@ -492,7 +492,7 @@ func gatewayCmd() {
fmt.Printf("Error starting gateway HTTP server: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Config API started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Printf("✓ Config API started on 127.0.0.1:%d\n", cfg.Gateway.Port)
// Setup cron tool and service
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled)
@ -539,7 +539,7 @@ func gatewayCmd() {
fmt.Println("⚠ Warning: No channels enabled")
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Printf("✓ Gateway started on 127.0.0.1:%d\n", cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())

View file

@ -2,6 +2,7 @@ package channels
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
@ -198,6 +199,13 @@ func (c *WebSocketChannel) maybeBroadcast(msg bus.OutboundMessage, clientType st
}
func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
if key := c.config.APIKey; key != "" {
if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("api_key")), []byte(key)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
conn, err := c.upgrader.Upgrade(w, r, nil)
if err != nil {
logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{

View file

@ -124,6 +124,7 @@ type WebSocketConfig struct {
Host string `json:"host" label:"Host" env:"CLAWDROID_CHANNELS_WEBSOCKET_HOST"`
Port int `json:"port" label:"Port" env:"CLAWDROID_CHANNELS_WEBSOCKET_PORT"`
Path string `json:"path" label:"Path" env:"CLAWDROID_CHANNELS_WEBSOCKET_PATH"`
APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_CHANNELS_WEBSOCKET_API_KEY"`
AllowFrom FlexibleStringSlice `json:"allow_from" label:"Allow From" env:"CLAWDROID_CHANNELS_WEBSOCKET_ALLOW_FROM"`
}
@ -138,7 +139,6 @@ type RateLimitsConfig struct {
}
type GatewayConfig struct {
Host string `json:"host" label:"Host" env:"CLAWDROID_GATEWAY_HOST"`
Port int `json:"port" label:"Port" env:"CLAWDROID_GATEWAY_PORT"`
APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_GATEWAY_API_KEY"`
}
@ -249,7 +249,6 @@ func DefaultConfig() *Config {
},
},
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{

View file

@ -67,9 +67,6 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig()
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
t.Error("Gateway port should have default value")
}
@ -207,9 +204,6 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero")
}
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
t.Error("Gateway port should have default value")
}

View file

@ -65,6 +65,11 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse {
continue
}
// Gateway is managed via the Connection screen, not the Backend Config UI.
if jsonTag == "gateway" {
continue
}
section := SchemaSection{
Key: jsonTag,
Label: labelTag(field),

View file

@ -33,7 +33,7 @@ func (s *Server) Start() error {
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port)
s.server = &http.Server{
Addr: addr,
Handler: mux,

View file

@ -57,7 +57,6 @@ func TestServerStop_NoServer_NoError(t *testing.T) {
func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1 // invalid port prevents real listen but Start still builds handler
cfg.Gateway.APIKey = "test-key"
@ -106,9 +105,9 @@ func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
func TestBuildSchema_SectionCount(t *testing.T) {
schema := BuildSchema(config.DefaultConfig())
// Config has exported fields: LLM, Agents, Channels, Gateway, Tools, Heartbeat, RateLimits
if len(schema.Sections) < 7 {
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections))
// Config has exported fields: LLM, Agents, Channels, Tools, Heartbeat, RateLimits (gateway excluded)
if len(schema.Sections) < 6 {
t.Errorf("expected at least 6 sections, got %d", len(schema.Sections))
}
}
@ -342,8 +341,6 @@ func TestBuildSchema_DefaultValues(t *testing.T) {
{"agents", "defaults.max_tokens", float64(8192)}, // JSON numbers → float64
{"agents", "defaults.context_window", float64(128000)},
{"agents", "defaults.restrict_to_workspace", true},
{"gateway", "host", "127.0.0.1"},
{"gateway", "port", float64(18790)},
{"heartbeat", "enabled", true},
{"heartbeat", "interval", float64(30)},
}
@ -387,8 +384,8 @@ func TestHandleGetSchema_Response(t *testing.T) {
t.Fatalf("failed to decode response: %v", err)
}
if len(schema.Sections) < 7 {
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections))
if len(schema.Sections) < 6 {
t.Errorf("expected at least 6 sections, got %d", len(schema.Sections))
}
// Verify each section has at least one field
@ -451,7 +448,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) {
cfg := config.DefaultConfig()
cfg.LLM.Model = "test-model"
cfg.LLM.BaseURL = "https://example.com"
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 9999
s := newTestServer(cfg)
@ -475,9 +471,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) {
}
gw := result["gateway"].(map[string]interface{})
if gw["host"] != "0.0.0.0" {
t.Errorf("gateway host = %v, want %q", gw["host"], "0.0.0.0")
}
if gw["port"] != float64(9999) {
t.Errorf("gateway port = %v, want %v", gw["port"], 9999)
}
@ -723,7 +716,6 @@ func TestHandlePutConfig_SaveError_500(t *testing.T) {
func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
cfg := config.DefaultConfig()
cfg.LLM.Model = "original-model"
cfg.Gateway.Host = "10.0.0.1"
cfg.Gateway.Port = 12345
cfg.Channels.Telegram.Enabled = true
cfg.Heartbeat.Interval = 60
@ -751,9 +743,6 @@ func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
if saved.LLM.Model != "new-model" {
t.Errorf("LLM model = %q, want %q", saved.LLM.Model, "new-model")
}
if saved.Gateway.Host != "10.0.0.1" {
t.Errorf("Gateway host = %q, want %q (should be preserved)", saved.Gateway.Host, "10.0.0.1")
}
if saved.Gateway.Port != 12345 {
t.Errorf("Gateway port = %d, want %d (should be preserved)", saved.Gateway.Port, 12345)
}
@ -1200,7 +1189,6 @@ func TestBuildSchema_SectionLabels(t *testing.T) {
func TestServerRouting_PutConfig_AuthRequired(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1
cfg.Gateway.APIKey = "route-test-key"
@ -2064,7 +2052,6 @@ func TestHandlePutConfig_ThenGetRoundTrip(t *testing.T) {
func TestServerStop_CalledTwice(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1
s := NewServer(cfg, "/tmp/config.json", nil)
@ -2085,7 +2072,6 @@ func TestServerStop_CalledTwice(t *testing.T) {
func TestServerRouting_UnsupportedMethods(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1
s := NewServer(cfg, "/tmp/config.json", nil)
@ -2107,7 +2093,6 @@ func TestServerRouting_UnsupportedMethods(t *testing.T) {
func TestServerRouting_UnknownPath(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1
s := NewServer(cfg, "/tmp/config.json", nil)
@ -2168,7 +2153,6 @@ func TestAuthMiddleware_TokenWithWhitespace_403(t *testing.T) {
func TestServerRouting_SchemaAuthRequired(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1
cfg.Gateway.APIKey = "schema-auth-key"
@ -2336,38 +2320,14 @@ func TestBuildSchema_RateLimitsSectionFields(t *testing.T) {
}
}
// --- #30: gateway section all fields in schema ---
// --- #30: gateway section excluded from schema ---
func TestBuildSchema_GatewaySectionFields(t *testing.T) {
func TestBuildSchema_GatewaySectionExcluded(t *testing.T) {
schema := BuildSchema(config.DefaultConfig())
var gw *SchemaSection
for i := range schema.Sections {
if schema.Sections[i].Key == "gateway" {
gw = &schema.Sections[i]
break
}
}
if gw == nil {
t.Fatal("gateway section not found")
}
keySet := map[string]bool{}
for _, f := range gw.Fields {
keySet[f.Key] = true
}
wantKeys := []string{"host", "port", "api_key"}
for _, k := range wantKeys {
if !keySet[k] {
t.Errorf("gateway field %q not found", k)
}
}
// api_key should be secret
for _, f := range gw.Fields {
if f.Key == "api_key" && !f.Secret {
t.Error("gateway api_key should be marked as secret")
for _, sec := range schema.Sections {
if sec.Key == "gateway" {
t.Error("gateway section should be excluded from schema")
}
}
}