discourse/plugins/discourse-ai/spec/requests/admin/ai_secrets_controller_spec.rb
Sam 10d109e4c8
FEATURE: add credential bindings for AI tools (#37891)
Introduce a "secret contracts" system that lets AI tools
declare required credentials by alias and bind them to
existing AiSecret records via a new join table.

Key changes:
- New `ai_tool_secret_bindings` table and AiToolSecretBinding
  model linking AiTool to AiSecret by alias
- `secret_contracts` JSONB column on ai_tools for declaring
  required credential aliases
- `secrets.get(alias)` API available in tool scripts at
  runtime, with preloaded secret caching
- Admin UI for managing credential contracts and bindings on
  the tool editor form, with status badges on the tool list
- Tool presets (stock_quote, image generation) updated to use
  `secrets.get()` instead of hardcoded placeholder keys
- Secrets list editor shows tool usages alongside LLM and
  embedding usages
- Test modal supports passing secret bindings and rendering
  custom_raw output
- Validation, error handling, and i18n for contracts/bindings
- Persona import/export updated to include secret_contracts

---------

Co-authored-by: Keegan George <kgeorge13@gmail.com>
Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
2026-02-20 07:56:15 +11:00

141 lines
4.2 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe DiscourseAi::Admin::AiSecretsController do
fab!(:admin)
fab!(:user)
fab!(:ai_secret)
before do
enable_current_plugin
sign_in(admin)
end
describe "#index" do
it "lists all secrets" do
get "/admin/plugins/discourse-ai/ai-secrets.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["ai_secrets"].length).to eq(1)
expect(json["ai_secrets"][0]["name"]).to eq(ai_secret.name)
expect(json["ai_secrets"][0]["secret"]).to eq("********")
end
it "includes tool usage in used_by" do
tool = Fabricate(:ai_tool, secret_contracts: [{ alias: "api_key" }])
AiToolSecretBinding.create!(ai_tool: tool, alias: "api_key", ai_secret_id: ai_secret.id)
get "/admin/plugins/discourse-ai/ai-secrets.json"
json = response.parsed_body
used_by = json["ai_secrets"][0]["used_by"]
expect(used_by.map { |item| item["type"] }).to include("tool")
end
it "requires admin" do
sign_in(user)
get "/admin/plugins/discourse-ai/ai-secrets.json"
expect(response.status).to eq(404)
end
end
describe "#show" do
it "returns the unmasked secret" do
get "/admin/plugins/discourse-ai/ai-secrets/#{ai_secret.id}.json"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["ai_secret"]["name"]).to eq(ai_secret.name)
expect(json["ai_secret"]["secret"]).to eq(ai_secret.secret)
end
end
describe "#create" do
it "creates a new secret" do
post "/admin/plugins/discourse-ai/ai-secrets.json",
params: {
ai_secret: {
name: "New Secret",
secret: "sk-new-key",
},
}
expect(response.status).to eq(201)
json = response.parsed_body
expect(json["ai_secret"]["name"]).to eq("New Secret")
expect(json["ai_secret"]["secret"]).to eq("********")
expect(AiSecret.last.secret).to eq("sk-new-key")
expect(AiSecret.last.created_by_id).to eq(admin.id)
end
it "returns errors for invalid params" do
post "/admin/plugins/discourse-ai/ai-secrets.json",
params: {
ai_secret: {
name: "",
secret: "",
},
}
expect(response.status).to eq(422)
end
end
describe "#update" do
it "updates a secret" do
put "/admin/plugins/discourse-ai/ai-secrets/#{ai_secret.id}.json",
params: {
ai_secret: {
name: "Updated Name",
secret: "new-secret-value",
},
}
expect(response.status).to eq(200)
ai_secret.reload
expect(ai_secret.name).to eq("Updated Name")
expect(ai_secret.secret).to eq("new-secret-value")
end
it "does not update secret when secret param is omitted" do
original_secret = ai_secret.secret
put "/admin/plugins/discourse-ai/ai-secrets/#{ai_secret.id}.json",
params: {
ai_secret: {
name: "Updated Name",
},
}
expect(response.status).to eq(200)
ai_secret.reload
expect(ai_secret.name).to eq("Updated Name")
expect(ai_secret.secret).to eq(original_secret)
end
end
describe "#destroy" do
it "deletes an unused secret" do
delete "/admin/plugins/discourse-ai/ai-secrets/#{ai_secret.id}.json"
expect(response.status).to eq(204)
expect(AiSecret.find_by(id: ai_secret.id)).to be_nil
end
it "refuses to delete a secret in use" do
Fabricate(:llm_model, ai_secret: ai_secret)
delete "/admin/plugins/discourse-ai/ai-secrets/#{ai_secret.id}.json"
expect(response.status).to eq(409)
expect(AiSecret.find_by(id: ai_secret.id)).to be_present
end
it "refuses to delete a secret used by a tool binding" do
tool = Fabricate(:ai_tool, secret_contracts: [{ alias: "api_key" }])
AiToolSecretBinding.create!(ai_tool: tool, alias: "api_key", ai_secret_id: ai_secret.id)
delete "/admin/plugins/discourse-ai/ai-secrets/#{ai_secret.id}.json"
expect(response.status).to eq(409)
expect(AiSecret.find_by(id: ai_secret.id)).to be_present
end
end
end