Files
warp_engine/spec/requests/service_controller_spec.rb
T
mr.zeroandClaude Opus 5 6c8b026590 WarpEngine 0.5.0: a catalog that can say a title is not yours
A desktop client reading /api/software had no way to learn that a title costs
money. There was nothing in the response to say so, no way to sign in, and no
way to be told "you do not own this" — so a store with paid titles could only
hand the client a 403 at download time and let it guess why.

The fix belongs here rather than in the client. A client serves more than one
store, so anything it knows about a particular one has to arrive from that
store's own API; a rule compiled into the client is a rule that breaks every
other catalog it reads. Three seams, each following the storage adapter's
shape — documented contract, default that is byte for byte the old behaviour,
one config key to replace it:

- **access policy** — visible_software_scope / access_for / authorize_download.
  Every catalog entry now carries an `access` block (gated, entitled, price,
  purchaseUrl, webUrl) and both /api/download and /file/* ask before serving.
  The vocabulary is deliberately generic: a word from one host's domain would
  make every client that reads it specific to that host.
- **client sign-in** — the device authorization grant (RFC 8628), over the
  host's own user model. The approval page stays the host's, because approving
  needs a session and HTML. Tokens are ApplicationTokens with a `catalog`
  scope, so publishing and reading stay separable.
- **service descriptor** — GET /api/service says what this deployment is and
  whether it has a sign-in at all, which is how a client stops guessing.

With no policy and no subject class configured — every deployment today — the
API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and
the 187 pre-existing examples pass untouched.

A policy that raises is treated as a refusal, not permission. An artifact
served because the gatekeeper crashed is the one failure mode this must not
have, so a broken policy empties the catalog and denies the download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:26:28 +02:00

76 lines
2.9 KiB
Ruby

require "rails_helper"
# The descriptor is how a client stops being built for one particular store: everything
# it used to have compiled in — is there a sign-in, where does it live, can titles be
# gated — is answered here instead.
RSpec.describe "GET /api/service", type: :request do
after { WarpEngine::AccessPolicy.reset! }
it "names the engine and its version" do
get "/api/service"
json = JSON.parse(response.body)
expect(json["engine"]).to eq("warp_engine")
expect(json["version"]).to eq(WarpEngine::VERSION)
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
it "reports an open catalog as ungated and offering no sign-in" do
get "/api/service"
json = JSON.parse(response.body)
expect(json["catalog"]).to eq("gated" => false)
expect(json["auth"]).to be_nil
end
it "reports a configured policy as a catalog that can gate" do
policy = Class.new do
def visible_software_scope(subject: nil) = WarpEngine::Software.all
def access_for(software:, subject: nil) = WarpEngine::Access::OPEN
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
end.new
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
get "/api/service"
expect(JSON.parse(response.body)["catalog"]).to eq("gated" => true)
end
describe "with a client identity configured" do
before do
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
allow(WarpEngine.config).to receive(:identity_verification_url).and_return("/devices")
end
it "describes the device flow, so a client needs no addresses of its own" do
get "/api/service"
auth = JSON.parse(response.body)["auth"]
expect(auth["schemes"]).to eq([ "bearer" ])
expect(auth["device"]["authorizeUrl"]).to eq("http://www.example.com/api/auth/device")
expect(auth["device"]["tokenUrl"]).to eq("http://www.example.com/api/auth/device/token")
expect(auth["device"]["revokeUrl"]).to eq("http://www.example.com/api/auth/token")
expect(auth["device"]["interval"]).to eq(5)
end
# A host that configured a bare path should not have to know its own hostname; one
# that put the approval page on another domain should keep it.
it "makes a configured path absolute against the request" do
get "/api/service"
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
.to eq("http://www.example.com/devices")
end
it "leaves an absolute verification URL alone" do
allow(WarpEngine.config).to receive(:identity_verification_url)
.and_return("https://accounts.example.org/devices")
get "/api/service"
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
.to eq("https://accounts.example.org/devices")
end
end
end