build endpoints
This commit is contained in:
@@ -66,8 +66,8 @@ The first boot takes a few minutes: the app container bundles, runs
|
|||||||
|
|
||||||
### Publish a release by hand
|
### Publish a release by hand
|
||||||
|
|
||||||
The updater contract needs nothing but files in the drop area and one HTTP
|
The updater contract is nothing but a handful of HTTP calls, so you can play
|
||||||
call, so you can play the role of the CI pipeline yourself:
|
the role of the CI pipeline yourself:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. Fake a build: metadata, a web build and a windows artifact, named by
|
# 1. Fake a build: metadata, a web build and a windows artifact, named by
|
||||||
@@ -78,14 +78,21 @@ JSON
|
|||||||
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
|
echo '<h1>demo</h1>' > 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
|
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin
|
||||||
|
|
||||||
# 2. Drop them (password: DROP_PASSWORD from .env, default "drop")
|
# 2. Upload them (one request per file)
|
||||||
scp -P 2222 demo-0.1.0.* drop@localhost:drop/
|
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
|
# 3. Publish the release
|
||||||
curl -H "X-Update-Secret: example-update-secret" \
|
curl -X POST -H "X-Update-Secret: example-update-secret" \
|
||||||
"http://localhost:8080/update?platform=love&name=demo&version=0.1.0"
|
"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,
|
`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
|
`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
|
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.
|
`http://woodpecker:8000` (OAuth via gitea) and enable your repository.
|
||||||
|
|
||||||
A pipeline publishes a release exactly like the by-hand steps above — build,
|
A pipeline publishes a release exactly like the by-hand steps above — build,
|
||||||
`scp` to `droparea`, call `/update`:
|
upload, publish:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .woodpecker.yaml in a game repo hosted on the example gitea
|
# .woodpecker.yaml in a game repo hosted on the example gitea
|
||||||
@@ -118,19 +125,17 @@ steps:
|
|||||||
publish:
|
publish:
|
||||||
image: alpine
|
image: alpine
|
||||||
environment:
|
environment:
|
||||||
DROP_PASSWORD:
|
|
||||||
from_secret: drop_password
|
|
||||||
UPDATE_SECRET:
|
UPDATE_SECRET:
|
||||||
from_secret: update_secret
|
from_secret: update_secret
|
||||||
commands:
|
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 ...
|
- # ... 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/
|
- 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 -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/update?platform=love&name=mygame&version=1.0.0"
|
- 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`
|
(The agent attaches pipeline containers to the stack network, so `app`
|
||||||
and `app` resolve. For real projects, the per-platform
|
resolves. For real projects, the per-platform
|
||||||
[`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made
|
[`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made
|
||||||
Makefile + pipeline templates implementing this contract.)
|
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.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||||
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||||
|
|
||||||
# Shared secret for the /update endpoint.
|
# Shared secret for the /build/* endpoints.
|
||||||
# nil => the endpoint rejects every request.
|
# nil => the endpoints reject every request.
|
||||||
c.update_secret = ENV["UPDATE_SECRET"]
|
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)
|
# :env — the shared secret above is accepted (default)
|
||||||
# :database — only WarpEngine::ApplicationToken records with the
|
# :database — only WarpEngine::ApplicationToken records with the
|
||||||
# "update" scope are accepted; the shared secret stops
|
# "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_source = :database
|
||||||
# c.application_token_owner_class = "AdminUser"
|
# 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
|
# If your app's own models reference catalog images, register them so the
|
||||||
# admin Images page counts them as "in use":
|
# admin Images page counts them as "in use":
|
||||||
# c.image_owners = [
|
# 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:
|
1. **Upload** build artifacts into `file_container_path`, named by convention:
|
||||||
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
|
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
|
||||||
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
|
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
|
||||||
declares which asset kinds it expects — see `GET /api/builds`).
|
declares which asset kinds it expects — see `GET /api/builds`). Either
|
||||||
2. **Call the endpoint**:
|
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
|
```sh
|
||||||
curl -H "X-Update-Secret: $UPDATE_SECRET" \
|
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
|
WarpEngine extracts the archives, parses the metadata (JSON, or the Lua
|
||||||
@@ -223,15 +246,16 @@ deleted records are resurrected on re-ingest.
|
|||||||
|
|
||||||
### Updater authentication
|
### Updater authentication
|
||||||
|
|
||||||
The `X-Update-Secret` header (or the `?secret=` query param) carries one of
|
The `X-Update-Secret` header carries one of two credentials, selected by
|
||||||
two credentials, selected by `application_token_source` — the modes are
|
`application_token_source` — the modes are exclusive, the endpoint never
|
||||||
exclusive, the endpoint never accepts both:
|
accepts both:
|
||||||
|
|
||||||
- **`:env`** (default): the single shared secret from `update_secret`.
|
- **`:env`** (default): the single shared secret from `update_secret`.
|
||||||
- **`:database`**: `WarpEngine::ApplicationToken` records. Each token
|
- **`:database`**: `WarpEngine::ApplicationToken` records. Each token
|
||||||
belongs to an owner (the class named by `application_token_owner_class`,
|
belongs to an owner (the class named by `application_token_owner_class`,
|
||||||
e.g. `AdminUser`), carries a free-form scope list — `/update` requires the
|
e.g. `AdminUser`), carries a free-form scope list — publishing requires
|
||||||
`"update"` scope — and an optional expiry. Tokens are created in the admin
|
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
|
(*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
|
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
|
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 |
|
| 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/software/highlighted` | The currently highlighted title |
|
||||||
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
||||||
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||||
actions :index, :show, :new, :create, :edit, :update, :destroy
|
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"
|
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("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("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
|
||||||
column("Scopes") { |t| t.scopes_string }
|
column("Scopes") { |t| t.scopes_string }
|
||||||
|
column :unrestricted
|
||||||
column :expires_at
|
column :expires_at
|
||||||
column :last_used_at
|
column :last_used_at
|
||||||
column :created_at
|
column :created_at
|
||||||
@@ -44,7 +45,8 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
|||||||
end
|
end
|
||||||
f.input :name
|
f.input :name
|
||||||
f.input :scopes_string, label: "Scopes (comma separated)",
|
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."
|
f.input :expires_at, hint: "Üresen hagyva sosem jár le."
|
||||||
end
|
end
|
||||||
f.actions
|
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("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("Owner") { |t| "#{t.owner_type} ##{t.owner_id} — #{t.owner.try(:email) || t.owner.try(:name)}" }
|
||||||
row("Scopes") { |t| t.scopes_string }
|
row("Scopes") { |t| t.scopes_string }
|
||||||
|
row :unrestricted
|
||||||
row :expires_at
|
row :expires_at
|
||||||
row :last_used_at
|
row :last_used_at
|
||||||
row :created_at
|
row :created_at
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,6 +29,7 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
|
|
||||||
api :GET, "/api/software", "List all software entries with releases"
|
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
|
returns code: 200, desc: "Wrapper object with softwares array" do
|
||||||
property :softwares, Array, desc: "Array of software entries" do
|
property :softwares, Array, desc: "Array of software entries" do
|
||||||
property :ID, Integer, desc: "Software ID"
|
property :ID, Integer, desc: "Software ID"
|
||||||
@@ -55,7 +56,7 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
def index
|
def index
|
||||||
render json: WarpEngine::SoftwareService.new.index
|
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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: <name>-<version>.<ext> vagy
|
||||||
|
# <name>-<version>-<target>.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 <name>-<version>)"
|
||||||
|
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
|
||||||
@@ -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
|
|
||||||
@@ -5,6 +5,7 @@ module WarpEngine
|
|||||||
self.table_name = "application_tokens"
|
self.table_name = "application_tokens"
|
||||||
|
|
||||||
UPDATE_SCOPE = "update".freeze
|
UPDATE_SCOPE = "update".freeze
|
||||||
|
UPLOAD_SCOPE = "upload".freeze
|
||||||
|
|
||||||
# A generált token csak létrehozáskor, memóriában érhető el — a DB-ben
|
# 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.
|
# kizárólag a SHA256 digest és a nem-titkos prefix tárolódik.
|
||||||
@@ -63,7 +64,7 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.ransackable_attributes(auth_object = nil)
|
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
|
end
|
||||||
|
|
||||||
# A polimorf owner asszociációra a Ransack nem tud szűrni.
|
# A polimorf owner asszociációra a Ransack nem tud szűrni.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ module WarpEngine
|
|||||||
class Software < ApplicationRecord
|
class Software < ApplicationRecord
|
||||||
self.table_name = "softwares"
|
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 :software_images, foreign_key: :software_id, dependent: :destroy
|
||||||
has_many :images, through: :software_images
|
has_many :images, through: :software_images
|
||||||
has_many :releases, foreign_key: :software_id
|
has_many :releases, foreign_key: :software_id
|
||||||
@@ -19,7 +23,7 @@ module WarpEngine
|
|||||||
default_scope { where(deleted_at: nil) }
|
default_scope { where(deleted_at: nil) }
|
||||||
|
|
||||||
def self.ransackable_attributes(auth_object = 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
|
end
|
||||||
|
|
||||||
def self.ransackable_associations(auth_object = nil)
|
def self.ransackable_associations(auth_object = nil)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ module WarpEngine
|
|||||||
field(:license) { |sw| sw.license.to_s }
|
field(:license) { |sw| sw.license.to_s }
|
||||||
field :platform
|
field :platform
|
||||||
field :status
|
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(:highlighted) { |sw| sw.highlighted ? true : false }
|
||||||
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
|
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
|
||||||
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
|
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
|
|
||||||
|
|
||||||
def upload(relative_dir, uploaded_file)
|
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)
|
dir = safe_path!(relative_dir)
|
||||||
raise ArgumentError, "Not a directory" unless dir.directory?
|
raise ArgumentError, "Not a directory" unless dir.directory?
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ module WarpEngine
|
|||||||
class SoftwareService
|
class SoftwareService
|
||||||
include SoftwareResponseBuilder
|
include SoftwareResponseBuilder
|
||||||
|
|
||||||
def index
|
def index(owner_id: nil)
|
||||||
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
|
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) })
|
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
|
||||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
|
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
|
||||||
end
|
end
|
||||||
|
|||||||
+3
-1
@@ -8,6 +8,8 @@ WarpEngine::Engine.routes.draw do
|
|||||||
get "softwares/:name/builds", to: "software_builds#show"
|
get "softwares/:name/builds", to: "software_builds#show"
|
||||||
end
|
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
|
get "file/*path", to: "files#show", format: false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
MYSQL_ROOT_PASSWORD=warpengine
|
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
|
UPDATE_SECRET=example-update-secret
|
||||||
|
|
||||||
# Password of the "drop" user on the artifact drop area (SSH, port 2222).
|
# Password of the "drop" user on the artifact drop area (SSH, port 2222).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Rails.application.config.to_prepare do
|
|||||||
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||||
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
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"]
|
c.update_secret = ENV["UPDATE_SECRET"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
|
|||||||
t.string :site
|
t.string :site
|
||||||
t.string :status, limit: 20, default: "development"
|
t.string :status, limit: 20, default: "development"
|
||||||
t.boolean :highlighted, default: false
|
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.datetime :deleted_at, precision: 3
|
||||||
t.timestamps precision: 3, null: true
|
t.timestamps precision: 3, null: true
|
||||||
t.index :name, unique: true
|
t.index :name, unique: true
|
||||||
|
t.index [ :owner_type, :owner_id ]
|
||||||
t.index :deleted_at
|
t.index :deleted_at
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -82,6 +87,8 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
|
|||||||
t.string :token_digest, limit: 64, null: false
|
t.string :token_digest, limit: 64, null: false
|
||||||
t.string :token_prefix, limit: 12, null: false
|
t.string :token_prefix, limit: 12, null: false
|
||||||
t.json :scopes
|
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 :expires_at, precision: 3
|
||||||
t.datetime :last_used_at, precision: 3
|
t.datetime :last_used_at, precision: 3
|
||||||
t.datetime :deleted_at, precision: 3
|
t.datetime :deleted_at, precision: 3
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ Rails.application.config.to_prepare do
|
|||||||
# c.file_container_path = "/softwares"
|
# c.file_container_path = "/softwares"
|
||||||
# c.image_container_path = "/images"
|
# c.image_container_path = "/images"
|
||||||
|
|
||||||
# A /update endpoint shared secretje (default: ENV["UPDATE_SECRET"]).
|
# A /build/* endpointok shared secretje (default: ENV["UPDATE_SECRET"]).
|
||||||
# Beállítatlan secret esetén az endpoint minden kérést elutasít.
|
# Beállítatlan secret esetén az endpointok minden kérést elutasítanak.
|
||||||
# c.update_secret = ENV["UPDATE_SECRET"]
|
# 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)
|
# :env — a fenti shared secret érvényes (default)
|
||||||
# :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes
|
# :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes
|
||||||
# ("update" scope-pal); a shared secret ilyenkor NEM működik.
|
# ("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_source = :database
|
||||||
# c.application_token_owner_class = "AdminUser"
|
# 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,
|
# Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket,
|
||||||
# hogy az admin Images oldal orphan-detektálása figyelembe vegye:
|
# hogy az admin Images oldal orphan-detektálása figyelembe vegye:
|
||||||
# c.image_owners = [
|
# c.image_owners = [
|
||||||
|
|||||||
@@ -4,16 +4,22 @@ module WarpEngine
|
|||||||
# label: String
|
# label: String
|
||||||
# image_ids: -> { Array<Integer> } — az owner által használt image id-k
|
# 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
|
# 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
|
# :env — a shared secret (update_secret) érvényes, a DB-tokenek nem
|
||||||
# :database — csak WarpEngine::ApplicationToken érvényes, a shared secret nem
|
# :database — csak WarpEngine::ApplicationToken érvényes, a shared secret nem
|
||||||
# application_token_owner_class: a tokenek kötelező tulajdonosának osztályneve
|
# 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.
|
# (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,
|
attr_accessor :file_container_path,
|
||||||
:image_container_path,
|
:image_container_path,
|
||||||
:update_secret,
|
:update_secret,
|
||||||
:application_token_source,
|
:application_token_source,
|
||||||
:application_token_owner_class,
|
:application_token_owner_class,
|
||||||
|
:max_upload_size,
|
||||||
|
:enforce_software_ownership,
|
||||||
:image_owners
|
:image_owners
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@@ -22,6 +28,8 @@ module WarpEngine
|
|||||||
@update_secret = ENV["UPDATE_SECRET"]
|
@update_secret = ENV["UPDATE_SECRET"]
|
||||||
@application_token_source = :env
|
@application_token_source = :env
|
||||||
@application_token_owner_class = nil
|
@application_token_owner_class = nil
|
||||||
|
@max_upload_size = 500 * 1024 * 1024
|
||||||
|
@enforce_software_ownership = false
|
||||||
@image_owners = []
|
@image_owners = []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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|
|
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 "created_at", precision: 3
|
||||||
t.datetime "deleted_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.json "scopes"
|
||||||
t.string "token_digest", limit: 64, null: false
|
t.string "token_digest", limit: 64, null: false
|
||||||
t.string "token_prefix", limit: 12, null: false
|
t.string "token_prefix", limit: 12, null: false
|
||||||
|
t.boolean "unrestricted", default: false, null: false
|
||||||
t.datetime "updated_at", precision: 3
|
t.datetime "updated_at", precision: 3
|
||||||
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
|
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
|
||||||
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
|
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.boolean "highlighted", default: false
|
||||||
t.string "license", limit: 128
|
t.string "license", limit: 128
|
||||||
t.string "name", 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 "platform", limit: 128
|
||||||
t.string "site"
|
t.string "site"
|
||||||
t.string "status", limit: 20, default: "development"
|
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.datetime "updated_at", precision: 3
|
||||||
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
|
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
|
||||||
t.index ["name"], name: "idx_softwares_name", unique: true
|
t.index ["name"], name: "idx_softwares_name", unique: true
|
||||||
|
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "test_owners", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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
|
trait :expired do
|
||||||
expires_at { 1.hour.ago }
|
expires_at { 1.hour.ago }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :unrestricted do
|
||||||
|
unrestricted { true }
|
||||||
|
end
|
||||||
end
|
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)
|
expect(counts[other.id]).to eq(1)
|
||||||
end
|
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
|
it "excludes soft-deleted software" do
|
||||||
create(:software, deleted_at: Time.current)
|
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