build endpoints
This commit is contained in:
@@ -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|
|
||||
|
||||
@@ -13,5 +13,9 @@ FactoryBot.define do
|
||||
trait :expired do
|
||||
expires_at { 1.hour.ago }
|
||||
end
|
||||
|
||||
trait :unrestricted do
|
||||
unrestricted { true }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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 <name>-<version>- 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 <name>-<version> 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user