Add DB-backed application tokens for the update endpoint

This commit is contained in:
2026-08-05 18:30:20 +02:00
parent 60e196a03e
commit 710594efda
14 changed files with 591 additions and 10 deletions
+3
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
+124
View File
@@ -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
+89
View File
@@ -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