mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 05:35:40 +08:00
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>
141 lines
4.2 KiB
Ruby
Vendored
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
|