gamora 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +87 -0
- data/Rakefile +19 -0
- data/app/controllers/gamora/application_controller.rb +6 -0
- data/app/controllers/gamora/authentication_controller.rb +31 -0
- data/app/controllers/gamora/callback_controller.rb +20 -0
- data/app/models/gamora/application_record.rb +7 -0
- data/config/routes.rb +6 -0
- data/lib/gamora/authentication/base.rb +58 -0
- data/lib/gamora/authentication/headers.rb +27 -0
- data/lib/gamora/authentication/session.rb +29 -0
- data/lib/gamora/client.rb +41 -0
- data/lib/gamora/configuration.rb +22 -0
- data/lib/gamora/engine.rb +7 -0
- data/lib/gamora/user.rb +13 -0
- data/lib/gamora/version.rb +5 -0
- data/lib/gamora.rb +19 -0
- data/lib/generators/gamora/install_generator.rb +14 -0
- data/lib/generators/gamora/templates/gamora.rb +19 -0
- data/lib/tasks/gamora_tasks.rake +6 -0
- metadata +96 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2cd6b80cc6b032dda4a916030c007de815ab7bbe86b9206797310fad36c7a70f
|
4
|
+
data.tar.gz: aad80d59d2dcf567445aade639916019cbc3e6570d3f9a73bde6bfd4647f26fe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 98b0c4b20abaecc2ed9042e6d04d565325b3fbc71a37f152520aec9f9f56043429843d13dd879415f961593dca7ee6f405b92f08ef85a122fe51813448e203bf
|
7
|
+
data.tar.gz: 6518ea2fdb5ed87f69afc422bd38ca1a82a3aea6b313ea6abef5adb057366b5d0f6fc16d11b002ca6b8bede834c90cb126f38880b19dc53bcb8d0e67e27584ea
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2022 Alejandro Gutiérrez
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
# Gamora - OIDC Relying Party
|
2
|
+
|
3
|
+
Gamora aims to provide most of the functionality that is commonly
|
4
|
+
required in an OpenID Connect Relying Party. An OIDC Relying Party is
|
5
|
+
an OAuth 2.0 Client application that requires user authentication and
|
6
|
+
claims from an OpenID Connect Provider (IdP). More information about
|
7
|
+
[OpenID Connect](https://openid.net/connect/).
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add `Gamora` to your application's Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem "gamora"
|
15
|
+
```
|
16
|
+
|
17
|
+
And then install gamora:
|
18
|
+
|
19
|
+
```bash
|
20
|
+
rails g gamora:install
|
21
|
+
```
|
22
|
+
|
23
|
+
## Configuration
|
24
|
+
|
25
|
+
Provide required configuration in `config/initializers/gamora.rb`:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
Gamora.setup do |config|
|
29
|
+
# ===> Required OAuth2 configuration options
|
30
|
+
config.client_id = "CLIENT_ID"
|
31
|
+
config.client_secret = "CLIENT_SECRET"
|
32
|
+
config.site = "IDENTITY_PROVIDER"
|
33
|
+
|
34
|
+
...
|
35
|
+
end
|
36
|
+
```
|
37
|
+
|
38
|
+
To see the full list of configuration options please check your gamora
|
39
|
+
initializer.
|
40
|
+
|
41
|
+
## User authentication
|
42
|
+
|
43
|
+
### Web-based applications
|
44
|
+
|
45
|
+
To authenticate the user against the Identity Provider before each request
|
46
|
+
using an access token stored in the session you should include
|
47
|
+
`Gamora::Authentication::Session` in your application controller and use the
|
48
|
+
before_action `authenticate_user!` in the actions you need to protect.
|
49
|
+
In case the access token has expired or is invalid. it will redirect the
|
50
|
+
user to the IdP to authenticate again.
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
class ApplicationController < ActionController::Base
|
54
|
+
include Gamora::Authentication::Session
|
55
|
+
...
|
56
|
+
|
57
|
+
before_action :authenticate_user!
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
### JSON API applications
|
62
|
+
|
63
|
+
In the other hand, if your application is an JSON API you probably want
|
64
|
+
to authenticate the user using the access token in the request headers.
|
65
|
+
To do that, you should include `Gamora::Authentication::Headers` in your
|
66
|
+
application controller and use the before_action `authenticate_user!` in
|
67
|
+
the actions you need to protect. In case the access token has expired or
|
68
|
+
is invalid. it will return an error json with unauthorized status.
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
class ApplicationController < ActionController::Base
|
72
|
+
include Gamora::Authentication::Headers
|
73
|
+
...
|
74
|
+
|
75
|
+
before_action :authenticate_user!
|
76
|
+
end
|
77
|
+
```
|
78
|
+
|
79
|
+
Optionally, if you want to do something different when authentication
|
80
|
+
fails, you just need to override the `user_authentication_failed!`
|
81
|
+
method in you controller and customize it as you wish.
|
82
|
+
|
83
|
+
## Contributing
|
84
|
+
Contribution directions go here.
|
85
|
+
|
86
|
+
## License
|
87
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
|
5
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
6
|
+
load "rails/tasks/engine.rake"
|
7
|
+
|
8
|
+
load "rails/tasks/statistics.rake"
|
9
|
+
|
10
|
+
require "bundler/gem_tasks"
|
11
|
+
require "rspec/core/rake_task"
|
12
|
+
|
13
|
+
RSpec::Core::RakeTask.new(:spec)
|
14
|
+
|
15
|
+
require "rubocop/rake_task"
|
16
|
+
|
17
|
+
RuboCop::RakeTask.new
|
18
|
+
|
19
|
+
task default: %i[spec rubocop]
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
class AuthenticationController < ApplicationController
|
5
|
+
def show
|
6
|
+
redirect_to authorization_url, allow_other_host: true
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def authorization_url
|
12
|
+
Client.from_config.auth_code.authorize_url({
|
13
|
+
scope: Configuration.default_scope,
|
14
|
+
prompt: Configuration.default_prompt,
|
15
|
+
strategy: Configuration.default_strategy,
|
16
|
+
ui_locales: Configuration.ui_locales.call
|
17
|
+
}.merge(authorization_params).compact_blank)
|
18
|
+
end
|
19
|
+
|
20
|
+
def authorization_params
|
21
|
+
params.permit(
|
22
|
+
:scope,
|
23
|
+
:state,
|
24
|
+
:prompt,
|
25
|
+
:max_age,
|
26
|
+
:strategy,
|
27
|
+
:ui_locales
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
class CallbackController < ApplicationController
|
5
|
+
def show
|
6
|
+
access_token = access_token_from_auth_code
|
7
|
+
session[:access_token] = access_token.token
|
8
|
+
session[:refresh_token] = access_token.refresh_token
|
9
|
+
redirect_to session.delete("gamora.origin") || main_app.root_path
|
10
|
+
rescue OAuth2::Error
|
11
|
+
render plain: "Invalid authorization code"
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def access_token_from_auth_code
|
17
|
+
Client.from_config.auth_code.get_token(params[:code])
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
module Authentication
|
5
|
+
module Base
|
6
|
+
def authenticate_user!
|
7
|
+
claims = resource_owner_claims(access_token)
|
8
|
+
assign_current_user_from_claims(claims) if claims.present?
|
9
|
+
validate_authentication!
|
10
|
+
end
|
11
|
+
|
12
|
+
def current_user
|
13
|
+
@current_user
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def validate_authentication!
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def access_token
|
23
|
+
raise NotImplementedError
|
24
|
+
end
|
25
|
+
|
26
|
+
def user_authentication_failed!
|
27
|
+
raise NotImplementedError
|
28
|
+
end
|
29
|
+
|
30
|
+
def assign_current_user_from_claims(claims)
|
31
|
+
attrs = user_attributes_from_claims(claims)
|
32
|
+
@current_user = User.new(attrs)
|
33
|
+
end
|
34
|
+
|
35
|
+
def user_attributes_from_claims(claims)
|
36
|
+
claims.transform_keys do |key|
|
37
|
+
case key
|
38
|
+
when :sub then :id
|
39
|
+
when :email then :email
|
40
|
+
when :given_name then :first_name
|
41
|
+
when :family_name then :last_name
|
42
|
+
when :phone_number then :phone_number
|
43
|
+
else key
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def resource_owner_claims(access_token)
|
49
|
+
return {} if access_token.blank?
|
50
|
+
oauth_client.userinfo(access_token)
|
51
|
+
end
|
52
|
+
|
53
|
+
def oauth_client
|
54
|
+
Client.from_config
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
module Authentication
|
5
|
+
module Headers
|
6
|
+
include Base
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def validate_authentication!
|
11
|
+
return if current_user.present?
|
12
|
+
user_authentication_failed!
|
13
|
+
end
|
14
|
+
|
15
|
+
def access_token
|
16
|
+
pattern = /^Bearer /
|
17
|
+
header = request.headers["Authorization"]
|
18
|
+
return unless header&.match(pattern)
|
19
|
+
header.gsub(pattern, "")
|
20
|
+
end
|
21
|
+
|
22
|
+
def user_authentication_failed!
|
23
|
+
render json: { error: "Access token invalid" }, status: :unauthorized
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
module Authentication
|
5
|
+
module Session
|
6
|
+
include Base
|
7
|
+
|
8
|
+
def self.included(base)
|
9
|
+
base.helper_method :current_user
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def validate_authentication!
|
15
|
+
return if current_user.present?
|
16
|
+
session["gamora.origin"] = request.original_url
|
17
|
+
user_authentication_failed!
|
18
|
+
end
|
19
|
+
|
20
|
+
def access_token
|
21
|
+
session[:access_token]
|
22
|
+
end
|
23
|
+
|
24
|
+
def user_authentication_failed!
|
25
|
+
redirect_to gamora.authentication_path
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
class Client < OAuth2::Client
|
5
|
+
def self.from_config
|
6
|
+
new(
|
7
|
+
Configuration.client_id,
|
8
|
+
Configuration.client_secret,
|
9
|
+
{
|
10
|
+
site: Configuration.site,
|
11
|
+
token_url: Configuration.token_url,
|
12
|
+
token_method: Configuration.token_method,
|
13
|
+
redirect_uri: Configuration.redirect_uri,
|
14
|
+
userinfo_url: Configuration.userinfo_url,
|
15
|
+
authorize_url: Configuration.authorize_url
|
16
|
+
}
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
def userinfo(access_token)
|
21
|
+
response = userinfo_request(access_token)
|
22
|
+
JSON.parse(response.body).symbolize_keys
|
23
|
+
rescue OAuth2::Error
|
24
|
+
{}
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def userinfo_request(access_token)
|
30
|
+
opts = userinfo_request_options(access_token)
|
31
|
+
request(:post, options[:userinfo_url], opts)
|
32
|
+
end
|
33
|
+
|
34
|
+
def userinfo_request_options(access_token)
|
35
|
+
{
|
36
|
+
body: { access_token: access_token }.to_json,
|
37
|
+
headers: { "Content-Type": "application/json" }
|
38
|
+
}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
module Configuration
|
5
|
+
mattr_accessor :client_id, default: nil
|
6
|
+
mattr_accessor :client_secret, default: nil
|
7
|
+
mattr_accessor :site, default: nil
|
8
|
+
mattr_accessor :token_url, default: "/oauth2/token"
|
9
|
+
mattr_accessor :authorize_url, default: "/oauth2/authorize"
|
10
|
+
mattr_accessor :userinfo_url, default: "/oauth2/userinfo"
|
11
|
+
mattr_accessor :token_method, default: :post
|
12
|
+
mattr_accessor :redirect_uri, default: nil
|
13
|
+
mattr_accessor :default_scope, default: "openid profile email"
|
14
|
+
mattr_accessor :default_prompt, default: nil
|
15
|
+
mattr_accessor :default_strategy, default: "default"
|
16
|
+
mattr_accessor :ui_locales, default: -> { I18n.locale }
|
17
|
+
|
18
|
+
def setup
|
19
|
+
yield(self) if block_given?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/gamora/user.rb
ADDED
data/lib/gamora.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "oauth2"
|
4
|
+
require "gamora/version"
|
5
|
+
require "gamora/engine"
|
6
|
+
require "gamora/configuration"
|
7
|
+
|
8
|
+
module Gamora
|
9
|
+
extend Configuration
|
10
|
+
|
11
|
+
autoload :User, "gamora/user"
|
12
|
+
autoload :Client, "gamora/client"
|
13
|
+
|
14
|
+
module Authentication
|
15
|
+
autoload :Base, "gamora/authentication/base"
|
16
|
+
autoload :Headers, "gamora/authentication/headers"
|
17
|
+
autoload :Session, "gamora/authentication/session"
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gamora
|
4
|
+
module Generators
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
7
|
+
desc "Creates gamora initializer."
|
8
|
+
|
9
|
+
def create_initializer
|
10
|
+
template "gamora.rb", "config/initializers/gamora.rb"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Gamora.setup do |config|
|
4
|
+
# ===> Required OAuth2 configuration options
|
5
|
+
config.client_id = "CLIENT_ID"
|
6
|
+
config.client_secret = "CLIENT_SECRET"
|
7
|
+
config.site = "IDENTITY_PROVIDER_URL"
|
8
|
+
|
9
|
+
# ===> Optional OAuth2 configuration options and its defaults.
|
10
|
+
# config.token_url = "/oauth2/token"
|
11
|
+
# config.authorize_url = "/oauth2/authorize"
|
12
|
+
# config.userinfo_url = "/oauth2/userinfo"
|
13
|
+
# config.token_method = :post
|
14
|
+
# config.redirect_uri = nil
|
15
|
+
# config.default_scope = "openid profile email"
|
16
|
+
# config.default_prompt = nil
|
17
|
+
# config.default_strategy = "default"
|
18
|
+
# config.ui_locales = -> { I18n.locale }
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gamora
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alejandro Gutiérrez
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-05-20 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: oauth2
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.4'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.4'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.0'
|
41
|
+
description: Gamora aims to provide most of the functionality that is commonly required
|
42
|
+
in an OIDC Client.
|
43
|
+
email:
|
44
|
+
- alejandrodevs@gmail.com
|
45
|
+
executables: []
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- MIT-LICENSE
|
50
|
+
- README.md
|
51
|
+
- Rakefile
|
52
|
+
- app/controllers/gamora/application_controller.rb
|
53
|
+
- app/controllers/gamora/authentication_controller.rb
|
54
|
+
- app/controllers/gamora/callback_controller.rb
|
55
|
+
- app/models/gamora/application_record.rb
|
56
|
+
- config/routes.rb
|
57
|
+
- lib/gamora.rb
|
58
|
+
- lib/gamora/authentication/base.rb
|
59
|
+
- lib/gamora/authentication/headers.rb
|
60
|
+
- lib/gamora/authentication/session.rb
|
61
|
+
- lib/gamora/client.rb
|
62
|
+
- lib/gamora/configuration.rb
|
63
|
+
- lib/gamora/engine.rb
|
64
|
+
- lib/gamora/user.rb
|
65
|
+
- lib/gamora/version.rb
|
66
|
+
- lib/generators/gamora/install_generator.rb
|
67
|
+
- lib/generators/gamora/templates/gamora.rb
|
68
|
+
- lib/tasks/gamora_tasks.rake
|
69
|
+
homepage: https://github.com/amco/gamora_rb
|
70
|
+
licenses:
|
71
|
+
- MIT
|
72
|
+
metadata:
|
73
|
+
homepage_uri: https://github.com/amco/gamora_rb
|
74
|
+
source_code_uri: https://github.com/amco/gamora_rb
|
75
|
+
changelog_uri: https://github.com/amco/gamora_rb/blob/master/CHANGELOG.md
|
76
|
+
rubygems_mfa_required: 'true'
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: 2.7.0
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
requirements: []
|
92
|
+
rubygems_version: 3.3.7
|
93
|
+
signing_key:
|
94
|
+
specification_version: 4
|
95
|
+
summary: OpenID Connect Relying Party for rails apps.
|
96
|
+
test_files: []
|