Add DB-backed application tokens for the update endpoint
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Csak a dummy app tesztjeihez: az ApplicationToken owner szerepét tölti be.
|
||||
class TestOwner < ActiveRecord::Base
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
# Csak a tesztekhez: az ApplicationToken owner-e (a hostban ez pl. AdminUser).
|
||||
class CreateTestOwners < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :test_owners, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
t.string :name, limit: 128
|
||||
t.timestamps precision: 3, null: true
|
||||
end
|
||||
end
|
||||
end
|
||||
+36
-2
@@ -1,5 +1,33 @@
|
||||
# A katalógus-táblák a host schema.rb-vel megegyező definícióval.
|
||||
ActiveRecord::Schema[8.1].define(version: 1) do
|
||||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# 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
|
||||
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
|
||||
t.datetime "expires_at", precision: 3
|
||||
t.datetime "last_used_at", precision: 3
|
||||
t.string "name", limit: 128, null: false
|
||||
t.bigint "owner_id", null: false, unsigned: true
|
||||
t.string "owner_type", limit: 128, null: false
|
||||
t.json "scopes"
|
||||
t.string "token_digest", limit: 64, null: false
|
||||
t.string "token_prefix", limit: 12, 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"
|
||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "downloads", 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
|
||||
@@ -99,6 +127,12 @@ ActiveRecord::Schema[8.1].define(version: 1) do
|
||||
t.index ["name"], name: "idx_softwares_name", unique: true
|
||||
end
|
||||
|
||||
create_table "test_owners", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.string "name", limit: 128
|
||||
t.datetime "updated_at", precision: 3
|
||||
end
|
||||
|
||||
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
|
||||
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
|
||||
add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# A TestOwner csak a dummy appban létezik — host-oldali használatnál az owner-t
|
||||
# felül kell írni (pl. owner: create(:admin_user)).
|
||||
FactoryBot.define do
|
||||
factory :test_owner, class: "TestOwner" do
|
||||
name { "test owner" }
|
||||
end
|
||||
|
||||
factory :application_token, class: "WarpEngine::ApplicationToken" do
|
||||
name { "CI token" }
|
||||
scopes { [ "update" ] }
|
||||
association :owner, factory: :test_owner
|
||||
|
||||
trait :expired do
|
||||
expires_at { 1.hour.ago }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::ApplicationToken, type: :model do
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
|
||||
end
|
||||
|
||||
describe "token generation" do
|
||||
it "generates a plain token on create and stores only its digest and prefix" do
|
||||
token = create(:application_token)
|
||||
|
||||
expect(token.plain_token).to match(/\A\h{48}\z/)
|
||||
expect(token.token_prefix).to eq(token.plain_token.first(8))
|
||||
expect(token.token_digest).to eq(Digest::SHA256.hexdigest(token.plain_token))
|
||||
end
|
||||
|
||||
it "does not expose the plain token on a reloaded record" do
|
||||
token = create(:application_token)
|
||||
|
||||
expect(described_class.find(token.id).plain_token).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "validations" do
|
||||
it "requires an owner" do
|
||||
token = build(:application_token, owner: nil)
|
||||
|
||||
expect(token).not_to be_valid
|
||||
expect(token.errors[:owner]).to be_present
|
||||
end
|
||||
|
||||
it "requires a name" do
|
||||
expect(build(:application_token, name: nil)).not_to be_valid
|
||||
end
|
||||
|
||||
it "requires at least one scope" do
|
||||
expect(build(:application_token, scopes: [])).not_to be_valid
|
||||
end
|
||||
|
||||
it "fills owner_type from the configuration" do
|
||||
token = create(:application_token)
|
||||
|
||||
expect(token.owner_type).to eq("TestOwner")
|
||||
end
|
||||
|
||||
it "rejects an owner_type differing from the configuration" do
|
||||
token = build(:application_token, owner_type: "WarpEngine::Software")
|
||||
|
||||
expect(token).not_to be_valid
|
||||
expect(token.errors[:owner_type]).to be_present
|
||||
end
|
||||
|
||||
it "rejects creation when no owner class is configured" do
|
||||
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return(nil)
|
||||
token = build(:application_token, owner_type: "TestOwner")
|
||||
|
||||
expect(token).not_to be_valid
|
||||
expect(token.errors[:base]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe "#scopes_string" do
|
||||
it "round-trips a comma separated list" do
|
||||
token = build(:application_token)
|
||||
token.scopes_string = "update, deploy,update ,"
|
||||
|
||||
expect(token.scopes).to eq(%w[update deploy])
|
||||
expect(token.scopes_string).to eq("update, deploy")
|
||||
end
|
||||
end
|
||||
|
||||
describe ".authenticate" do
|
||||
it "returns the token for a valid plain token and scope" do
|
||||
token = create(:application_token)
|
||||
|
||||
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to eq(token)
|
||||
end
|
||||
|
||||
it "returns nil for a blank or unknown token" do
|
||||
create(:application_token)
|
||||
|
||||
expect(described_class.authenticate(nil)).to be_nil
|
||||
expect(described_class.authenticate("")).to be_nil
|
||||
expect(described_class.authenticate("nem-letezo")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil when the required scope is missing" do
|
||||
token = create(:application_token, scopes: [ "deploy" ])
|
||||
|
||||
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for an expired token" do
|
||||
token = create(:application_token, :expired)
|
||||
|
||||
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for a revoked token" do
|
||||
token = create(:application_token)
|
||||
token.revoke!
|
||||
|
||||
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "#revoke!" do
|
||||
it "soft deletes the token" do
|
||||
token = create(:application_token)
|
||||
token.revoke!
|
||||
|
||||
expect(described_class.find_by(id: token.id)).to be_nil
|
||||
expect(described_class.unscoped.find(token.id).deleted_at).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe "#touch_last_used!" do
|
||||
it "stamps last_used_at" do
|
||||
token = create(:application_token)
|
||||
|
||||
expect { token.touch_last_used! }.to change { token.reload.last_used_at }.from(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -43,4 +43,93 @@ RSpec.describe "GET /update", type: :request do
|
||||
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 update_secret_source :database" do
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:update_secret_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