Add DB-backed application tokens for the update endpoint
This commit is contained in:
@@ -15,7 +15,9 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
|
||||
- **CI-callable updater**: your build pipeline drops artifacts into a
|
||||
directory and calls one endpoint — WarpEngine extracts archives, parses
|
||||
metadata and upserts the catalog records. Supported platforms out of the
|
||||
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser.
|
||||
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
|
||||
a shared secret or by per-owner database tokens with expiry and scopes
|
||||
(`ApplicationToken`, managed in the admin).
|
||||
- **Public JSON API**: catalog listing, highlighted title, per-platform build
|
||||
matrix, image serving, download tracking, and a static file server for
|
||||
web-playable builds.
|
||||
@@ -177,6 +179,15 @@ Rails.application.config.to_prepare do
|
||||
# nil => the endpoint rejects every request.
|
||||
c.update_secret = ENV["UPDATE_SECRET"]
|
||||
|
||||
# Authentication source for /update — 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
|
||||
# working the moment you switch.
|
||||
# :database mode also requires the owner class every token belongs to:
|
||||
# c.update_secret_source = :database
|
||||
# c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# If your app's own models reference catalog images, register them so the
|
||||
# admin Images page counts them as "in use":
|
||||
# c.image_owners = [
|
||||
@@ -210,6 +221,25 @@ comment header for TIC-80), and upserts the `Software`, `ExternalLink`,
|
||||
`Release` and `ReleaseAsset` records in a single transaction. Previously
|
||||
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 `update_secret_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
|
||||
(*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
|
||||
token last authenticated successfully.
|
||||
|
||||
When switching to `:database`, create the tokens and move your pipelines to
|
||||
them first — the flip invalidates the shared secret immediately.
|
||||
|
||||
## Public API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
|
||||
menu priority: 9, label: "🎟️ App Tokens"
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
column :name
|
||||
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 :expires_at
|
||||
column :last_used_at
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :name_cont, label: "Name"
|
||||
filter :token_prefix_cont, label: "Token prefix"
|
||||
filter :expires_at
|
||||
filter :last_used_at
|
||||
|
||||
form do |f|
|
||||
owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize
|
||||
f.inputs do
|
||||
if f.object.new_record?
|
||||
if owner_class
|
||||
f.input :owner_id, as: :select, label: owner_class.name,
|
||||
collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] },
|
||||
include_blank: false
|
||||
else
|
||||
f.template.concat(f.template.content_tag(:li,
|
||||
"application_token_owner_class nincs beállítva — token nem hozható létre.",
|
||||
class: "flash flash_error"))
|
||||
end
|
||||
end
|
||||
f.input :name
|
||||
f.input :scopes_string, label: "Scopes (comma separated)",
|
||||
hint: %(A /update végponthoz az "update" scope kell.)
|
||||
f.input :expires_at, hint: "Üresen hagyva sosem jár le."
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
show do
|
||||
if (plain = controller.instance_variable_get(:@plain_token))
|
||||
panel "⚠️ Token — csak most látható, másold ki!" do
|
||||
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
|
||||
end
|
||||
end
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
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 :expires_at
|
||||
row :last_used_at
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
# A plain token csak közvetlenül a létrehozás után létezik; a session-ön át
|
||||
# jut el az egyszeri megjelenítésig (a flash nem jó: az AA layout minden
|
||||
# flash kulcsot üzenetsávként renderel).
|
||||
def create
|
||||
create! do |success, _failure|
|
||||
success.html do
|
||||
session[:warp_engine_plain_token] = resource.plain_token
|
||||
redirect_to resource_path(resource) and return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@plain_token = session.delete(:warp_engine_plain_token)
|
||||
show!
|
||||
end
|
||||
|
||||
# Revoke = soft delete, audit-nyommal.
|
||||
def destroy
|
||||
resource.revoke!
|
||||
redirect_to collection_path, notice: "Token revoked."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ module WarpEngine
|
||||
end
|
||||
|
||||
api :GET, "/update", "Update software version in database"
|
||||
param :secret, String, required: true, desc: "Authorization secret"
|
||||
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"
|
||||
@@ -39,11 +39,35 @@ module WarpEngine
|
||||
|
||||
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?
|
||||
secret = request.headers["X-Update-Secret"].presence || params[:secret]
|
||||
token = request.headers["X-Update-Secret"].presence || params[:secret].presence
|
||||
return false if token.blank?
|
||||
|
||||
case WarpEngine.config.update_secret_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? && secret == expected
|
||||
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] update_secret_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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
class ApplicationToken < ApplicationRecord
|
||||
self.table_name = "application_tokens"
|
||||
|
||||
UPDATE_SCOPE = "update".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.
|
||||
attr_reader :plain_token
|
||||
|
||||
belongs_to :owner, polymorphic: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
before_validation :assign_owner_type, on: :create
|
||||
before_validation :generate_token, on: :create
|
||||
after_initialize { self.scopes = [] if new_record? && scopes.nil? }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :token_prefix, presence: true
|
||||
validates :scopes, presence: true
|
||||
validate :owner_type_matches_configuration
|
||||
|
||||
def self.digest(token)
|
||||
Digest::SHA256.hexdigest(token)
|
||||
end
|
||||
|
||||
# Az élő (nem törölt, nem lejárt), a kért scope-pal rendelkező token, különben nil.
|
||||
def self.authenticate(token, required_scope: nil)
|
||||
return nil if token.blank?
|
||||
|
||||
record = active.find_by(token_digest: digest(token))
|
||||
return nil if record.nil?
|
||||
return nil if required_scope.present? && !Array(record.scopes).include?(required_scope)
|
||||
|
||||
record
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Visszavonás = soft delete, az audit-nyom megmarad.
|
||||
def revoke!
|
||||
update_column(:deleted_at, Time.current)
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
# Admin form: vesszővel elválasztott scope-lista
|
||||
def scopes_string
|
||||
Array(scopes).join(", ")
|
||||
end
|
||||
|
||||
def scopes_string=(value)
|
||||
self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
|
||||
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]
|
||||
end
|
||||
|
||||
# A polimorf owner asszociációra a Ransack nem tud szűrni.
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_owner_type
|
||||
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
||||
end
|
||||
|
||||
def generate_token
|
||||
return if token_digest.present?
|
||||
|
||||
@plain_token = SecureRandom.hex(24)
|
||||
self.token_prefix = @plain_token.first(8)
|
||||
self.token_digest = self.class.digest(@plain_token)
|
||||
end
|
||||
|
||||
def owner_type_matches_configuration
|
||||
expected = WarpEngine.config.application_token_owner_class
|
||||
if expected.blank?
|
||||
errors.add(:base, "application_token_owner_class is not configured")
|
||||
elsif owner_type != expected
|
||||
errors.add(:owner_type, "must be #{expected}")
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_application_token, self)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class CreateApplicationTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :application_tokens, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
t.string :name, limit: 128, null: false
|
||||
# Az owner osztályát a host adja (WarpEngine.config.application_token_owner_class),
|
||||
# ezért nem lehet FK.
|
||||
t.string :owner_type, limit: 128, null: false
|
||||
t.bigint :owner_id, null: false, unsigned: true
|
||||
t.string :token_digest, limit: 64, null: false
|
||||
t.string :token_prefix, limit: 12, null: false
|
||||
t.json :scopes
|
||||
t.datetime :expires_at, precision: 3
|
||||
t.datetime :last_used_at, precision: 3
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3, null: true
|
||||
t.index :token_digest, name: "idx_application_tokens_token_digest", unique: true
|
||||
t.index [ :owner_type, :owner_id ], name: "idx_application_tokens_owner"
|
||||
t.index :deleted_at, name: "idx_application_tokens_deleted_at"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -75,6 +75,22 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
|
||||
t.index [ :software_id, :position ]
|
||||
end
|
||||
|
||||
create_table :application_tokens do |t|
|
||||
t.string :name, limit: 128, null: false
|
||||
t.string :owner_type, limit: 128, null: false
|
||||
t.bigint :owner_id, null: false
|
||||
t.string :token_digest, limit: 64, null: false
|
||||
t.string :token_prefix, limit: 12, null: false
|
||||
t.json :scopes
|
||||
t.datetime :expires_at, precision: 3
|
||||
t.datetime :last_used_at, precision: 3
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3, null: true
|
||||
t.index :token_digest, unique: true
|
||||
t.index [ :owner_type, :owner_id ]
|
||||
t.index :deleted_at
|
||||
end
|
||||
|
||||
create_table :downloads do |t|
|
||||
t.string :file_path, null: false
|
||||
t.references :release, foreign_key: { on_delete: :nullify }, index: false
|
||||
|
||||
@@ -9,6 +9,14 @@ Rails.application.config.to_prepare do
|
||||
# Beállítatlan secret esetén az endpoint minden kérést elutasít.
|
||||
# c.update_secret = ENV["UPDATE_SECRET"]
|
||||
|
||||
# A /update 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.
|
||||
# A :database módhoz kötelező a tokenek tulajdonos-osztálya is:
|
||||
# c.update_secret_source = :database
|
||||
# c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# 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 = [
|
||||
|
||||
@@ -4,16 +4,25 @@ module WarpEngine
|
||||
# label: String
|
||||
# image_ids: -> { Array<Integer> } — az owner által használt image id-k
|
||||
# usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja
|
||||
# update_secret_source: a /update endpoint 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.
|
||||
attr_accessor :file_container_path,
|
||||
:image_container_path,
|
||||
:update_secret,
|
||||
:update_secret_source,
|
||||
:application_token_owner_class,
|
||||
:image_owners
|
||||
|
||||
def initialize
|
||||
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
@image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
@update_secret = ENV["UPDATE_SECRET"]
|
||||
@image_owners = []
|
||||
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
@image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
@update_secret = ENV["UPDATE_SECRET"]
|
||||
@update_secret_source = :env
|
||||
@application_token_owner_class = nil
|
||||
@image_owners = []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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