From 4e2c45dc45e22b066967c90cb997cb1a45f7304a Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 5 Aug 2026 20:12:35 +0200 Subject: [PATCH] build endpoints --- README.md | 78 ++++++--- app/admin/application_tokens.rb | 7 +- .../warp_engine/update_authentication.rb | 71 ++++++++ .../warp_engine/api/software_controller.rb | 3 +- .../warp_engine/build/publish_controller.rb | 45 +++++ .../warp_engine/build/uploads_controller.rb | 65 +++++++ .../warp_engine/update_controller.rb | 73 -------- app/models/warp_engine/application_token.rb | 3 +- app/models/warp_engine/software.rb | 6 +- .../warp_engine/software_serializer.rb | 2 + .../warp_engine/file_manager_service.rb | 5 +- app/services/warp_engine/software_service.rb | 3 +- config/routes.rb | 4 +- .../20260805000003_add_build_ownership.rb | 12 ++ examples/compose/.env.example | 2 +- .../config/initializers/warp_engine.rb | 2 +- .../templates/create_warp_engine_tables.rb | 7 + .../install/templates/initializer.rb | 14 +- lib/warp_engine/configuration.rb | 10 +- spec/dummy/db/schema.rb | 6 +- spec/factories/application_tokens.rb | 4 + .../requests/build_publish_controller_spec.rb | 122 +++++++++++++ .../requests/build_uploads_controller_spec.rb | 161 ++++++++++++++++++ spec/requests/software_controller_spec.rb | 15 ++ spec/requests/update_controller_spec.rb | 135 --------------- 25 files changed, 603 insertions(+), 252 deletions(-) create mode 100644 app/controllers/concerns/warp_engine/update_authentication.rb create mode 100644 app/controllers/warp_engine/build/publish_controller.rb create mode 100644 app/controllers/warp_engine/build/uploads_controller.rb delete mode 100644 app/controllers/warp_engine/update_controller.rb create mode 100644 db/migrate/20260805000003_add_build_ownership.rb create mode 100644 spec/requests/build_publish_controller_spec.rb create mode 100644 spec/requests/build_uploads_controller_spec.rb delete mode 100644 spec/requests/update_controller_spec.rb diff --git a/README.md b/README.md index 768bbc4..228bf05 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ The first boot takes a few minutes: the app container bundles, runs ### Publish a release by hand -The updater contract needs nothing but files in the drop area and one HTTP -call, so you can play the role of the CI pipeline yourself: +The updater contract is nothing but a handful of HTTP calls, so you can play +the role of the CI pipeline yourself: ```sh # 1. Fake a build: metadata, a web build and a windows artifact, named by @@ -78,14 +78,21 @@ JSON echo '

demo

' > index.html && zip demo-0.1.0.html.zip index.html echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin -# 2. Drop them (password: DROP_PASSWORD from .env, default "drop") -scp -P 2222 demo-0.1.0.* drop@localhost:drop/ +# 2. Upload them (one request per file) +for f in demo-0.1.0.*; do + curl -fs -H "X-Update-Secret: example-update-secret" \ + -F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0" +done -# 3. Trigger the updater -curl -H "X-Update-Secret: example-update-secret" \ - "http://localhost:8080/update?platform=love&name=demo&version=0.1.0" +# 3. Publish the release +curl -X POST -H "X-Update-Secret: example-update-secret" \ + "http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0" ``` +(Dropping the files in over the SSH drop area — `scp -P 2222 demo-0.1.0.* +drop@localhost:drop/`, password `DROP_PASSWORD` from `.env` — works just as +well; the updater only cares that the files end up in `file_container_path`.) + `GET /api/software` now lists *Demo Game* with `html` and `win_x64` assets, `http://localhost:8080/file/demo-0.1.0/index.html` serves the extracted web build, and `GET /api/download?path=demo-0.1.0-win-x64.zip` serves the @@ -110,7 +117,7 @@ echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts `http://woodpecker:8000` (OAuth via gitea) and enable your repository. A pipeline publishes a release exactly like the by-hand steps above — build, -`scp` to `droparea`, call `/update`: +upload, publish: ```yaml # .woodpecker.yaml in a game repo hosted on the example gitea @@ -118,19 +125,17 @@ steps: publish: image: alpine environment: - DROP_PASSWORD: - from_secret: drop_password UPDATE_SECRET: from_secret: update_secret commands: - - apk add --no-cache openssh-client sshpass curl zip + - apk add --no-cache curl zip - # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ... - - sshpass -p "$DROP_PASSWORD" scp -P 2222 -o StrictHostKeyChecking=no mygame-1.0.0.* drop@droparea:drop/ - - curl -fs -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/update?platform=love&name=mygame&version=1.0.0" + - for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done + - curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0" ``` -(The agent attaches pipeline containers to the stack network, so `droparea` -and `app` resolve. For real projects, the per-platform +(The agent attaches pipeline containers to the stack network, so `app` +resolves. For real projects, the per-platform [`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made Makefile + pipeline templates implementing this contract.) @@ -175,11 +180,11 @@ Rails.application.config.to_prepare do c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") - # Shared secret for the /update endpoint. - # nil => the endpoint rejects every request. + # Shared secret for the /build/* endpoints. + # nil => the endpoints reject every request. c.update_secret = ENV["UPDATE_SECRET"] - # Authentication source for /update — an exclusive choice: + # Authentication source for /build/* — an exclusive choice: # :env — the shared secret above is accepted (default) # :database — only WarpEngine::ApplicationToken records with the # "update" scope are accepted; the shared secret stops @@ -188,6 +193,14 @@ Rails.application.config.to_prepare do # c.application_token_source = :database # c.application_token_owner_class = "AdminUser" + # Size cap for /build/upload and the admin file manager, in bytes (default 500MB). + # c.max_upload_size = 500 * 1024 * 1024 + + # Owner isolation: a database token may only upload/publish softwares + # owned by its own owner (unrestricted tokens are exempt). Enable only + # after backfilling owners — ownerless softwares are claimable by anyone. + # c.enforce_software_ownership = true + # If your app's own models reference catalog images, register them so the # admin Images page counts them as "in use": # c.image_owners = [ @@ -208,12 +221,22 @@ Publishing a release from CI is two steps: 1. **Upload** build artifacts into `file_container_path`, named by convention: `-.metadata.json`, `-.html.zip`, `--win-x64.zip`, `-.tic`, ... (each platform - declares which asset kinds it expects — see `GET /api/builds`). -2. **Call the endpoint**: + declares which asset kinds it expects — see `GET /api/builds`). Either + drop the files in over the shared volume (SSH drop area), or push them + over HTTP — one request per file, `upload` scope, optional `sha256` + integrity check: ```sh curl -H "X-Update-Secret: $UPDATE_SECRET" \ - "https://your-host/update?platform=tic80&name=mygame&version=1.2.0" + -F "file=@mygame-1.2.0.html.zip" \ + "https://your-host/build/upload?name=mygame&version=1.2.0" + ``` + +2. **Publish the release**: + + ```sh + curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \ + "https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0" ``` WarpEngine extracts the archives, parses the metadata (JSON, or the Lua @@ -223,15 +246,16 @@ deleted records are resurrected on re-ingest. ### Updater authentication -The `X-Update-Secret` header (or the `?secret=` query param) carries one of -two credentials, selected by `application_token_source` — the modes are -exclusive, the endpoint never accepts both: +The `X-Update-Secret` header carries one of two credentials, selected by +`application_token_source` — the modes are exclusive, the endpoint never +accepts both: - **`:env`** (default): the single shared secret from `update_secret`. - **`:database`**: `WarpEngine::ApplicationToken` records. Each token belongs to an owner (the class named by `application_token_owner_class`, - e.g. `AdminUser`), carries a free-form scope list — `/update` requires the - `"update"` scope — and an optional expiry. Tokens are created in the admin + e.g. `AdminUser`), carries a free-form scope list — publishing requires + the `"update"` scope, `/build/upload` the `"upload"` scope — and an + optional expiry. Tokens are created in the admin (*App Tokens*): the plain token is generated server-side and shown exactly once after creation; only its SHA256 digest is stored. Deleting a token in the admin revokes it (soft delete), and `last_used_at` records when each @@ -244,7 +268,7 @@ them first — the flip invalidates the shared secret immediately. | Endpoint | Purpose | | --- | --- | -| `GET /api/software` | Full catalog with releases, assets, links, download counts | +| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher | | `GET /api/software/highlighted` | The currently highlighted title | | `GET /api/builds` | Expected asset kinds per platform (build matrix) | | `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release | diff --git a/app/admin/application_tokens.rb b/app/admin/application_tokens.rb index 5caa103..f7f45f2 100644 --- a/app/admin/application_tokens.rb +++ b/app/admin/application_tokens.rb @@ -1,6 +1,6 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do actions :index, :show, :new, :create, :edit, :update, :destroy - permit_params :name, :owner_id, :expires_at, :scopes_string + permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted menu priority: 9, label: "🎟️ App Tokens" @@ -17,6 +17,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do column("Token") { |t| code "#{t.token_prefix}…", style: "font-family:monospace;" } column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" } column("Scopes") { |t| t.scopes_string } + column :unrestricted column :expires_at column :last_used_at column :created_at @@ -44,7 +45,8 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do end f.input :name f.input :scopes_string, label: "Scopes (comma separated)", - hint: %(A /update végponthoz az "update" scope kell.) + hint: %(A /build/publish (és a legacy /update) végponthoz az "update", a /build/upload-hoz az "upload" scope kell.) + f.input :unrestricted, hint: "Belső token: az owner-izoláció (enforce_software_ownership) nem vonatkozik rá." f.input :expires_at, hint: "Üresen hagyva sosem jár le." end f.actions @@ -62,6 +64,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest tárolva)" } row("Owner") { |t| "#{t.owner_type} ##{t.owner_id} — #{t.owner.try(:email) || t.owner.try(:name)}" } row("Scopes") { |t| t.scopes_string } + row :unrestricted row :expires_at row :last_used_at row :created_at diff --git a/app/controllers/concerns/warp_engine/update_authentication.rb b/app/controllers/concerns/warp_engine/update_authentication.rb new file mode 100644 index 0000000..7ae69c3 --- /dev/null +++ b/app/controllers/concerns/warp_engine/update_authentication.rb @@ -0,0 +1,71 @@ +module WarpEngine + # Token-hitelesítés a publikáló (/build/*) endpointokhoz. + # A hitelesítési forrás kizárólagos: :database módban a shared secret nem + # érvényes, :env módban a DB-tokenek nem. + module UpdateAuthentication + extend ActiveSupport::Concern + + private + + attr_reader :current_application_token + + # A token kizárólag az X-Update-Secret headerből jöhet — URL-ben a secret + # proxy- és access-logokba szivárogna. + def update_authorized?(required_scope:) + token = request.headers["X-Update-Secret"].presence + return false if token.blank? + + case WarpEngine.config.application_token_source + when :database then database_token_authorized?(token, required_scope) + else env_secret_authorized?(token) + end + end + + def env_secret_authorized?(token) + expected = WarpEngine.config.update_secret + # Konfigurálatlan secret esetén az endpoint zárva marad. + expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected) + end + + def database_token_authorized?(token, required_scope) + if WarpEngine.config.application_token_owner_class.blank? + Rails.logger.error("[#{self.class.name}] application_token_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva") + return false + end + + record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope) + return false if record.nil? + + record.touch_last_used! + @current_application_token = record + true + end + + # Owner-kényszer: csak :database módban (van token) és bekapcsolt + # enforce_software_ownership mellett szűr. Owner nélküli software a + # backfillig szabad préda — a kényszer bekapcsolása előtt kell backfillelni. + def software_ownership_authorized?(name) + return true unless WarpEngine.config.enforce_software_ownership + + token = current_application_token + return true if token.nil? || token.unrestricted? + + software = WarpEngine::Software.find_by(name: name) + return true if software.nil? || software.owner_id.nil? + + software.owner_type == token.owner_type && software.owner_id == token.owner_id + end + + # Az először publikált (vagy backfill előtti, gazdátlan) software a beküldő + # token ownerét kapja. Unrestricted (belső) token nem foglal ownert. + def claim_software_ownership(name) + token = current_application_token + return if token.nil? || token.unrestricted? + + software = WarpEngine::Software.find_by(name: name) + return if software.nil? || software.owner_id.present? + + software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id) + end + end +end diff --git a/app/controllers/warp_engine/api/software_controller.rb b/app/controllers/warp_engine/api/software_controller.rb index 3a67aeb..b45d038 100644 --- a/app/controllers/warp_engine/api/software_controller.rb +++ b/app/controllers/warp_engine/api/software_controller.rb @@ -29,6 +29,7 @@ module WarpEngine end api :GET, "/api/software", "List all software entries with releases" + param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)" returns code: 200, desc: "Wrapper object with softwares array" do property :softwares, Array, desc: "Array of software entries" do property :ID, Integer, desc: "Software ID" @@ -55,7 +56,7 @@ module WarpEngine end end def index - render json: WarpEngine::SoftwareService.new.index + render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id]) end end end diff --git a/app/controllers/warp_engine/build/publish_controller.rb b/app/controllers/warp_engine/build/publish_controller.rb new file mode 100644 index 0000000..3c108f9 --- /dev/null +++ b/app/controllers/warp_engine/build/publish_controller.rb @@ -0,0 +1,45 @@ +module WarpEngine + module Build + class PublishController < ApiController + include UpdateAuthentication + + resource_description do + short "Build release publishing" + end + + api :POST, "/build/publish", "Register an uploaded build as a release" + header "X-Update-Secret", "Shared secret or application token (update scope)", required: true + param :name, String, required: true, desc: "Software name" + param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)" + param :version, String, required: true, desc: "Version string" + returns code: 200, desc: "JSON with the published name/platform/version" + error code: 401, desc: "Invalid secret" + error code: 403, desc: "Token does not own this software" + error code: 400, desc: "Missing or invalid arguments" + def create + unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE) + return render json: { error: "Unauthorized" }, status: :unauthorized + end + + %i[name platform version].each do |key| + return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank? + end + + unless software_ownership_authorized?(params[:name]) + return render json: { error: "Forbidden" }, status: :forbidden + end + + input = WarpEngine::UpdateInputDto.new( + platform: params[:platform], + name: params[:name], + version: params[:version] + ) + + WarpEngine::UpdateService.new.update(input) + claim_software_ownership(params[:name]) + + render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] } + end + end + end +end diff --git a/app/controllers/warp_engine/build/uploads_controller.rb b/app/controllers/warp_engine/build/uploads_controller.rb new file mode 100644 index 0000000..02144bd --- /dev/null +++ b/app/controllers/warp_engine/build/uploads_controller.rb @@ -0,0 +1,65 @@ +require "digest" + +module WarpEngine + module Build + class UploadsController < ApiController + include UpdateAuthentication + + resource_description do + short "Build artifact upload" + end + + # A release-fájlnevek kötött konvenciója: -. vagy + # --.zip — az updater is ezeket keresi. + NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/ + + api :POST, "/build/upload", "Upload a build artifact into the drop area" + header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true + param :name, String, required: true, desc: "Software name (filename must be prefixed with -)" + param :version, String, required: true, desc: "Version string" + param :file, File, required: true, desc: "Artifact file (multipart)" + param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected" + returns code: 200, desc: "JSON with stored file name, size and sha256" + error code: 401, desc: "Invalid secret" + error code: 403, desc: "Token does not own this software" + error code: 400, desc: "Missing or invalid arguments" + error code: 413, desc: "File larger than max_upload_size" + error code: 422, desc: "SHA256 mismatch" + def create + unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE) + return render json: { error: "Unauthorized" }, status: :unauthorized + end + + name = params[:name].to_s + version = params[:version].to_s + file = params[:file] + + return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT) + return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT) + return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename) + + unless software_ownership_authorized?(name) + return render json: { error: "Forbidden" }, status: :forbidden + end + + filename = File.basename(file.original_filename.to_s) + unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-") + return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request + end + + max = WarpEngine.config.max_upload_size + if file.size > max + return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large + end + + digest = Digest::SHA256.file(file.tempfile.path).hexdigest + if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest) + return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity + end + + stored = WarpEngine::FileManagerService.new.upload("", file) + render json: { file: stored, size: file.size, sha256: digest } + end + end + end +end diff --git a/app/controllers/warp_engine/update_controller.rb b/app/controllers/warp_engine/update_controller.rb deleted file mode 100644 index d5c89b3..0000000 --- a/app/controllers/warp_engine/update_controller.rb +++ /dev/null @@ -1,73 +0,0 @@ -module WarpEngine - class UpdateController < ApiController - resource_description do - short "Software updater" - formats [ "text" ] - end - - rescue_from ArgumentError do |e| - render plain: e.message, status: :bad_request - end - - rescue_from StandardError do |e| - Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}") - render plain: "Internal server error", status: :internal_server_error - end - - api :GET, "/update", "Update software version in database" - param :secret, String, required: true, desc: "Shared secret or application token (X-Update-Secret header preferred)" - param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)" - param :name, String, required: false, desc: "Software name" - param :version, String, required: true, desc: "Version string" - returns code: 200, desc: "Plain text 'Updated'" - error code: 401, desc: "Invalid secret" - error code: 400, desc: "Version not provided or invalid arguments" - error code: 500, desc: "Internal server error" - def update - return render plain: "Unauthorized", status: :unauthorized unless authorized? - return render plain: "Version not provided", status: :bad_request if params[:version].blank? - - input = WarpEngine::UpdateInputDto.new( - platform: params[:platform], - name: params[:name], - version: params[:version] - ) - - WarpEngine::UpdateService.new.update(input) - render plain: "Updated" - end - - private - - # A hitelesítési forrás kizárólagos: :database módban a shared secret nem - # érvényes, :env módban a DB-tokenek nem. - def authorized? - token = request.headers["X-Update-Secret"].presence || params[:secret].presence - return false if token.blank? - - case WarpEngine.config.application_token_source - when :database then database_token_authorized?(token) - else env_secret_authorized?(token) - end - end - - def env_secret_authorized?(token) - expected = WarpEngine.config.update_secret - # Konfigurálatlan secret esetén az endpoint zárva marad. - expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected) - end - - def database_token_authorized?(token) - if WarpEngine.config.application_token_owner_class.blank? - Rails.logger.error("[UpdateController] application_token_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva") - return false - end - - record = WarpEngine::ApplicationToken.authenticate(token, required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE) - return false if record.nil? - - record.touch_last_used! - true - end - end -end diff --git a/app/models/warp_engine/application_token.rb b/app/models/warp_engine/application_token.rb index a6916d5..1262b9e 100644 --- a/app/models/warp_engine/application_token.rb +++ b/app/models/warp_engine/application_token.rb @@ -5,6 +5,7 @@ module WarpEngine self.table_name = "application_tokens" UPDATE_SCOPE = "update".freeze + UPLOAD_SCOPE = "upload".freeze # A generált token csak létrehozáskor, memóriában érhető el — a DB-ben # kizárólag a SHA256 digest és a nem-titkos prefix tárolódik. @@ -63,7 +64,7 @@ module WarpEngine end def self.ransackable_attributes(auth_object = nil) - %w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix updated_at] + %w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at] end # A polimorf owner asszociációra a Ransack nem tud szűrni. diff --git a/app/models/warp_engine/software.rb b/app/models/warp_engine/software.rb index b6d4d32..3083fc8 100644 --- a/app/models/warp_engine/software.rb +++ b/app/models/warp_engine/software.rb @@ -2,6 +2,10 @@ module WarpEngine class Software < ApplicationRecord self.table_name = "softwares" + # A publikáló token ownere (pl. AdminUser) — 3rd party izolációhoz, ld. + # enforce_software_ownership. nil = belső / backfill előtti software. + belongs_to :owner, polymorphic: true, optional: true + has_many :software_images, foreign_key: :software_id, dependent: :destroy has_many :images, through: :software_images has_many :releases, foreign_key: :software_id @@ -19,7 +23,7 @@ module WarpEngine default_scope { where(deleted_at: nil) } def self.ransackable_attributes(auth_object = nil) - %w[author created_at desc highlighted id license name platform site status story title updated_at] + %w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at] end def self.ransackable_associations(auth_object = nil) diff --git a/app/serializers/warp_engine/software_serializer.rb b/app/serializers/warp_engine/software_serializer.rb index 069a472..1d970fe 100644 --- a/app/serializers/warp_engine/software_serializer.rb +++ b/app/serializers/warp_engine/software_serializer.rb @@ -14,6 +14,8 @@ module WarpEngine field(:license) { |sw| sw.license.to_s } field :platform field :status + # Publikus owner-azonosító — az /api/software?owner_id= szűrőhöz. + field(:ownerId) { |sw| sw.owner_id } field(:highlighted) { |sw| sw.highlighted ? true : false } field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) } diff --git a/app/services/warp_engine/file_manager_service.rb b/app/services/warp_engine/file_manager_service.rb index 59e52e7..baff15d 100644 --- a/app/services/warp_engine/file_manager_service.rb +++ b/app/services/warp_engine/file_manager_service.rb @@ -22,10 +22,9 @@ module WarpEngine end end - MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB - def upload(relative_dir, uploaded_file) - raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE + max = WarpEngine.config.max_upload_size + raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max dir = safe_path!(relative_dir) raise ArgumentError, "Not a directory" unless dir.directory? diff --git a/app/services/warp_engine/software_service.rb b/app/services/warp_engine/software_service.rb index 45b2174..06ad14e 100644 --- a/app/services/warp_engine/software_service.rb +++ b/app/services/warp_engine/software_service.rb @@ -2,8 +2,9 @@ module WarpEngine class SoftwareService include SoftwareResponseBuilder - def index + def index(owner_id: nil) softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all + softwares = softwares.where(owner_id: owner_id) if owner_id.present? counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) }) { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } } end diff --git a/config/routes.rb b/config/routes.rb index 852dfc9..ab90223 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,8 @@ WarpEngine::Engine.routes.draw do get "softwares/:name/builds", to: "software_builds#show" end - get "update", to: "update#update" + post "build/upload", to: "build/uploads#create" + post "build/publish", to: "build/publish#create" + get "file/*path", to: "files#show", format: false end diff --git a/db/migrate/20260805000003_add_build_ownership.rb b/db/migrate/20260805000003_add_build_ownership.rb new file mode 100644 index 0000000..34ec166 --- /dev/null +++ b/db/migrate/20260805000003_add_build_ownership.rb @@ -0,0 +1,12 @@ +class AddBuildOwnership < ActiveRecord::Migration[8.1] + def change + # A publikáló token ownere; nil = belső / backfill előtti software. + # Az owner osztályát a host adja (application_token_owner_class), ezért nem lehet FK. + add_column :softwares, :owner_type, :string, limit: 128 + add_column :softwares, :owner_id, :bigint, unsigned: true + add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner" + + # unrestricted = belső token: az enforce_software_ownership nem vonatkozik rá. + add_column :application_tokens, :unrestricted, :boolean, default: false, null: false + end +end diff --git a/examples/compose/.env.example b/examples/compose/.env.example index 6f2a416..3f208ba 100644 --- a/examples/compose/.env.example +++ b/examples/compose/.env.example @@ -3,7 +3,7 @@ MYSQL_ROOT_PASSWORD=warpengine -# Shared secret for the /update endpoint (X-Update-Secret header). +# Shared secret for the /build/* endpoints (X-Update-Secret header). UPDATE_SECRET=example-update-secret # Password of the "drop" user on the artifact drop area (SSH, port 2222). diff --git a/examples/compose/host_app/config/initializers/warp_engine.rb b/examples/compose/host_app/config/initializers/warp_engine.rb index 2ad62d8..e5d041a 100644 --- a/examples/compose/host_app/config/initializers/warp_engine.rb +++ b/examples/compose/host_app/config/initializers/warp_engine.rb @@ -3,7 +3,7 @@ Rails.application.config.to_prepare do c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") - # Beállítatlan secret esetén a /update endpoint minden kérést elutasít. + # Beállítatlan secret esetén a /build/* endpointok minden kérést elutasítanak. c.update_secret = ENV["UPDATE_SECRET"] end end diff --git a/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb b/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb index cf69239..ac4d783 100644 --- a/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb +++ b/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb @@ -11,9 +11,14 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.string :site t.string :status, limit: 20, default: "development" t.boolean :highlighted, default: false + # A publikáló token ownere (enforce_software_ownership) — nem lehet FK, + # az owner osztályát a host adja. + t.string :owner_type, limit: 128 + t.bigint :owner_id t.datetime :deleted_at, precision: 3 t.timestamps precision: 3, null: true t.index :name, unique: true + t.index [ :owner_type, :owner_id ] t.index :deleted_at end @@ -82,6 +87,8 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.string :token_digest, limit: 64, null: false t.string :token_prefix, limit: 12, null: false t.json :scopes + # Belső token: az enforce_software_ownership nem vonatkozik rá. + t.boolean :unrestricted, default: false, null: false t.datetime :expires_at, precision: 3 t.datetime :last_used_at, precision: 3 t.datetime :deleted_at, precision: 3 diff --git a/lib/generators/warp_engine/install/templates/initializer.rb b/lib/generators/warp_engine/install/templates/initializer.rb index 5c7a26f..3f740a1 100644 --- a/lib/generators/warp_engine/install/templates/initializer.rb +++ b/lib/generators/warp_engine/install/templates/initializer.rb @@ -5,11 +5,11 @@ Rails.application.config.to_prepare do # c.file_container_path = "/softwares" # c.image_container_path = "/images" - # A /update endpoint shared secretje (default: ENV["UPDATE_SECRET"]). - # Beállítatlan secret esetén az endpoint minden kérést elutasít. + # A /build/* endpointok shared secretje (default: ENV["UPDATE_SECRET"]). + # Beállítatlan secret esetén az endpointok minden kérést elutasítanak. # c.update_secret = ENV["UPDATE_SECRET"] - # A /update hitelesítési forrása — kizárólagos választás: + # A /build/* hitelesítési forrása — kizárólagos választás: # :env — a fenti shared secret érvényes (default) # :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes # ("update" scope-pal); a shared secret ilyenkor NEM működik. @@ -17,6 +17,14 @@ Rails.application.config.to_prepare do # c.application_token_source = :database # c.application_token_owner_class = "AdminUser" + # A /build/upload (és az admin file manager) méretplafonja bájtban (default: 500MB). + # c.max_upload_size = 500 * 1024 * 1024 + + # Owner-izoláció: DB-token csak a saját ownerének szoftvereit + # uploadolhatja/publisholhatja (unrestricted token kivétel). Csak azután + # kapcsold be, hogy a meglévő szoftverek ownert kaptak (backfill)! + # c.enforce_software_ownership = true + # Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket, # hogy az admin Images oldal orphan-detektálása figyelembe vegye: # c.image_owners = [ diff --git a/lib/warp_engine/configuration.rb b/lib/warp_engine/configuration.rb index ee30e86..aaeed4a 100644 --- a/lib/warp_engine/configuration.rb +++ b/lib/warp_engine/configuration.rb @@ -4,16 +4,22 @@ module WarpEngine # label: String # image_ids: -> { Array } — az owner által használt image id-k # usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja - # application_token_source: a /update endpoint hitelesítési forrása, kizárólagos. + # application_token_source: a /build/* endpointok hitelesítési forrása, kizárólagos. # :env — a shared secret (update_secret) érvényes, a DB-tokenek nem # :database — csak WarpEngine::ApplicationToken érvényes, a shared secret nem # application_token_owner_class: a tokenek kötelező tulajdonosának osztályneve # (pl. "AdminUser"); nil esetén a :database mód minden kérést elutasít. + # max_upload_size: a /build/upload (és az admin file manager) fájlméret-plafonja bájtban. + # enforce_software_ownership: ha true, egy DB-token csak a saját ownerének + # szoftvereit uploadolhatja/publisholhatja (unrestricted token kivétel). + # Bekapcsolás CSAK backfill után: gazdátlan software-t bármely token elvihet. attr_accessor :file_container_path, :image_container_path, :update_secret, :application_token_source, :application_token_owner_class, + :max_upload_size, + :enforce_software_ownership, :image_owners def initialize @@ -22,6 +28,8 @@ module WarpEngine @update_secret = ENV["UPDATE_SECRET"] @application_token_source = :env @application_token_owner_class = nil + @max_upload_size = 500 * 1024 * 1024 + @enforce_software_ownership = false @image_owners = [] end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index d16be21..70ebb16 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_05_000002) do +ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", precision: 3 t.datetime "deleted_at", precision: 3 @@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000002) do t.json "scopes" t.string "token_digest", limit: 64, null: false t.string "token_prefix", limit: 12, null: false + t.boolean "unrestricted", default: false, null: false t.datetime "updated_at", precision: 3 t.index ["deleted_at"], name: "idx_application_tokens_deleted_at" t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner" @@ -117,6 +118,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000002) do t.boolean "highlighted", default: false t.string "license", limit: 128 t.string "name", limit: 128 + t.bigint "owner_id", unsigned: true + t.string "owner_type", limit: 128 t.string "platform", limit: 128 t.string "site" t.string "status", limit: 20, default: "development" @@ -125,6 +128,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000002) do t.datetime "updated_at", precision: 3 t.index ["deleted_at"], name: "idx_softwares_deleted_at" t.index ["name"], name: "idx_softwares_name", unique: true + t.index ["owner_type", "owner_id"], name: "idx_softwares_owner" end create_table "test_owners", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/spec/factories/application_tokens.rb b/spec/factories/application_tokens.rb index 43dc525..ea95411 100644 --- a/spec/factories/application_tokens.rb +++ b/spec/factories/application_tokens.rb @@ -13,5 +13,9 @@ FactoryBot.define do trait :expired do expires_at { 1.hour.ago } end + + trait :unrestricted do + unrestricted { true } + end end end diff --git a/spec/requests/build_publish_controller_spec.rb b/spec/requests/build_publish_controller_spec.rb new file mode 100644 index 0000000..2197f32 --- /dev/null +++ b/spec/requests/build_publish_controller_spec.rb @@ -0,0 +1,122 @@ +require "rails_helper" + +RSpec.describe "POST /build/publish", type: :request do + before do + allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret") + end + + def stub_updater + updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service) + allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater) + allow(updater).to receive(:update) + updater + end + + def publish(headers: { "X-Update-Secret" => "s3cret" }, params: {}) + post "/build/publish", headers: headers, + params: { name: "game", platform: "tic80", version: "1.0" }.merge(params) + end + + it "rejects requests without a secret" do + publish(headers: {}) + + expect(response).to have_http_status(:unauthorized) + end + + it "does not accept the secret as a query param" do + post "/build/publish", params: { secret: "s3cret", name: "game", platform: "tic80", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + + it "runs the updater and returns the published release" do + updater = stub_updater + expect(updater).to receive(:update).with("game", "1.0") + + publish + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to include("published" => true, "name" => "game", + "platform" => "tic80", "version" => "1.0") + end + + it "requires name, platform and version" do + %i[name platform version].each do |key| + publish(params: { key => "" }) + + expect(response).to have_http_status(:bad_request) + end + end + + it "rejects an unsupported platform" do + publish(params: { platform: "amiga" }) + + expect(response).to have_http_status(:bad_request) + end + + context "with application_token_source :database" do + before do + allow(WarpEngine.config).to receive(:application_token_source).and_return(:database) + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") + stub_updater + end + + it "accepts a token with the update scope" do + token = create(:application_token) + + publish(headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:ok) + end + + it "rejects the ENV shared secret" do + publish + + expect(response).to have_http_status(:unauthorized) + end + + context "with enforce_software_ownership" do + before { allow(WarpEngine.config).to receive(:enforce_software_ownership).and_return(true) } + + let(:token) { create(:application_token) } + + it "rejects publishing another owner's software" do + create(:software, name: "game", owner: create(:test_owner)) + + publish(headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:forbidden) + end + + it "claims an ownerless software for the token owner" do + software = create(:software, name: "game") + + publish(headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:ok) + expect(software.reload.owner).to eq(token.owner) + end + + it "does not claim ownership with an unrestricted token" do + software = create(:software, name: "game") + internal = create(:application_token, :unrestricted) + + publish(headers: { "X-Update-Secret" => internal.plain_token }) + + expect(response).to have_http_status(:ok) + expect(software.reload.owner_id).to be_nil + end + + it "keeps the existing owner on republish" do + owner = create(:test_owner) + software = create(:software, name: "game", owner: owner) + token = create(:application_token, owner: owner) + + publish(headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:ok) + expect(software.reload.owner).to eq(owner) + end + end + end +end diff --git a/spec/requests/build_uploads_controller_spec.rb b/spec/requests/build_uploads_controller_spec.rb new file mode 100644 index 0000000..c9d84a3 --- /dev/null +++ b/spec/requests/build_uploads_controller_spec.rb @@ -0,0 +1,161 @@ +require "rails_helper" +require "tmpdir" +require "digest" + +RSpec.describe "POST /build/upload", type: :request do + let(:tmpdir) { Dir.mktmpdir } + + before do + allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret") + allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir) + end + + after { FileUtils.rm_rf(tmpdir) } + + def artifact(filename, content: "zipdata") + path = File.join(Dir.mktmpdir, filename) + File.write(path, content) + Rack::Test::UploadedFile.new(path, "application/zip") + end + + def upload(file:, name: "game", version: "1.0", headers: { "X-Update-Secret" => "s3cret" }, extra: {}) + post "/build/upload", headers: headers, + params: { name: name, version: version, file: file }.merge(extra) + end + + it "rejects requests without a secret" do + upload(file: artifact("game-1.0.html.zip"), headers: {}) + + expect(response).to have_http_status(:unauthorized) + end + + it "does not accept the secret as a query param" do + post "/build/upload", params: { secret: "s3cret", name: "game", version: "1.0", + file: artifact("game-1.0.html.zip") } + + expect(response).to have_http_status(:unauthorized) + end + + it "stores a valid artifact and returns its digest" do + file = artifact("game-1.0.html.zip", content: "zipdata") + upload(file: file) + + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body["file"]).to eq("game-1.0.html.zip") + expect(body["sha256"]).to eq(Digest::SHA256.hexdigest("zipdata")) + expect(File.read(File.join(tmpdir, "game-1.0.html.zip"))).to eq("zipdata") + end + + it "accepts binary target artifacts with the -- prefix" do + upload(file: artifact("game-1.0-win-x64.zip")) + + expect(response).to have_http_status(:ok) + end + + it "rejects a filename outside the - convention" do + upload(file: artifact("other-2.0.html.zip")) + + expect(response).to have_http_status(:bad_request) + expect(File.exist?(File.join(tmpdir, "other-2.0.html.zip"))).to be(false) + end + + it "requires name and version" do + post "/build/upload", headers: { "X-Update-Secret" => "s3cret" }, + params: { file: artifact("game-1.0.html.zip") } + + expect(response).to have_http_status(:bad_request) + end + + it "requires a file" do + post "/build/upload", headers: { "X-Update-Secret" => "s3cret" }, + params: { name: "game", version: "1.0" } + + expect(response).to have_http_status(:bad_request) + end + + it "rejects a file over max_upload_size" do + allow(WarpEngine.config).to receive(:max_upload_size).and_return(3) + + upload(file: artifact("game-1.0.html.zip", content: "toolarge")) + + expect(response).to have_http_status(:payload_too_large) + end + + it "verifies a provided sha256 and rejects a mismatch" do + upload(file: artifact("game-1.0.html.zip"), extra: { sha256: "0" * 64 }) + + expect(response).to have_http_status(:unprocessable_entity) + expect(File.exist?(File.join(tmpdir, "game-1.0.html.zip"))).to be(false) + end + + it "accepts a matching sha256" do + upload(file: artifact("game-1.0.html.zip", content: "zipdata"), + extra: { sha256: Digest::SHA256.hexdigest("zipdata") }) + + expect(response).to have_http_status(:ok) + end + + context "with application_token_source :database" do + before do + allow(WarpEngine.config).to receive(:application_token_source).and_return(:database) + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") + end + + it "accepts a token with the upload scope" do + token = create(:application_token, scopes: [ "update", "upload" ]) + + upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:ok) + expect(token.reload.last_used_at).to be_present + end + + it "rejects a token without the upload scope" do + token = create(:application_token, scopes: [ "update" ]) + + upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:unauthorized) + end + + context "with enforce_software_ownership" do + before { allow(WarpEngine.config).to receive(:enforce_software_ownership).and_return(true) } + + let(:token) { create(:application_token, scopes: [ "upload" ]) } + + it "allows uploading to the token owner's software" do + create(:software, name: "game", owner: token.owner) + + upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:ok) + end + + it "rejects uploading to another owner's software" do + create(:software, name: "game", owner: create(:test_owner)) + + upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:forbidden) + end + + it "allows an unrestricted token regardless of owner" do + create(:software, name: "game", owner: create(:test_owner)) + internal = create(:application_token, :unrestricted, scopes: [ "upload" ]) + + upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => internal.plain_token }) + + expect(response).to have_http_status(:ok) + end + + it "allows uploading to an ownerless software" do + create(:software, name: "game") + + upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token }) + + expect(response).to have_http_status(:ok) + end + end + end +end diff --git a/spec/requests/software_controller_spec.rb b/spec/requests/software_controller_spec.rb index fdebabe..e21e821 100644 --- a/spec/requests/software_controller_spec.rb +++ b/spec/requests/software_controller_spec.rb @@ -30,6 +30,21 @@ RSpec.describe "GET /api/software", type: :request do expect(counts[other.id]).to eq(1) end + it "filters by owner_id and exposes ownerId" do + owner = create(:test_owner) + mine = create(:software, name: "mine", owner: owner) + create(:software, name: "other", owner: create(:test_owner)) + create(:software, name: "ownerless") + + get "/api/software", params: { owner_id: owner.id } + + json = JSON.parse(response.body) + expect(json["softwares"].length).to eq(1) + expect(json["softwares"].first["software"]["name"]).to eq("mine") + expect(json["softwares"].first["software"]["ownerId"]).to eq(owner.id) + expect(mine.reload.owner).to eq(owner) + end + it "excludes soft-deleted software" do create(:software, deleted_at: Time.current) diff --git a/spec/requests/update_controller_spec.rb b/spec/requests/update_controller_spec.rb deleted file mode 100644 index 6bf8626..0000000 --- a/spec/requests/update_controller_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -require "rails_helper" - -RSpec.describe "GET /update", type: :request do - before do - allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret") - end - - it "rejects requests without a secret" do - get "/update", params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "rejects requests with a wrong secret" do - get "/update", params: { secret: "wrong", platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "rejects every request when no secret is configured" do - allow(WarpEngine.config).to receive(:update_secret).and_return(nil) - - get "/update", params: { secret: "", platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "requires a version" do - get "/update", headers: { "X-Update-Secret" => "s3cret" }, params: { platform: "tic80", name: "game" } - - expect(response).to have_http_status(:bad_request) - expect(response.body).to eq("Version not provided") - end - - it "runs the updater with a valid secret" do - updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service) - allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater) - expect(updater).to receive(:update).with("game", "1.0") - - get "/update", headers: { "X-Update-Secret" => "s3cret" }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:ok) - expect(response.body).to eq("Updated") - end - - it "rejects a database token in :env mode" do - allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") - token = create(:application_token) - - get "/update", headers: { "X-Update-Secret" => token.plain_token }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - context "with application_token_source :database" do - before do - allow(WarpEngine.config).to receive(:application_token_source).and_return(:database) - allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") - end - - def stub_updater - updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service) - allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater) - allow(updater).to receive(:update) - end - - it "runs the updater with a valid token and stamps last_used_at" do - stub_updater - token = create(:application_token) - - get "/update", headers: { "X-Update-Secret" => token.plain_token }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:ok) - expect(response.body).to eq("Updated") - expect(token.reload.last_used_at).to be_present - end - - it "accepts the token via the secret param" do - stub_updater - token = create(:application_token) - - get "/update", params: { secret: token.plain_token, platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:ok) - end - - it "rejects the ENV shared secret" do - get "/update", headers: { "X-Update-Secret" => "s3cret" }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "rejects a token without the update scope" do - token = create(:application_token, scopes: [ "deploy" ]) - - get "/update", headers: { "X-Update-Secret" => token.plain_token }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "rejects an expired token" do - token = create(:application_token, :expired) - - get "/update", headers: { "X-Update-Secret" => token.plain_token }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "rejects a revoked token" do - token = create(:application_token) - token.revoke! - - get "/update", headers: { "X-Update-Secret" => token.plain_token }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - - it "rejects every request when no owner class is configured" do - token = create(:application_token) - allow(WarpEngine.config).to receive(:application_token_owner_class).and_return(nil) - - get "/update", headers: { "X-Update-Secret" => token.plain_token }, - params: { platform: "tic80", name: "game", version: "1.0" } - - expect(response).to have_http_status(:unauthorized) - end - end -end