- <% @rs_auths.each do |auth| %>
- <%= render RsAuthComponent.new(auth: auth) %>
+ <% if @rs_auth_groups.any? %>
+
+ <% @rs_auth_groups.each do |authorizations| %>
+ <%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
<% else %>
diff --git a/spec/features/services/rs_auths_spec.rb b/spec/features/services/rs_auths_spec.rb
new file mode 100644
index 0000000..9fd69b0
--- /dev/null
+++ b/spec/features/services/rs_auths_spec.rb
@@ -0,0 +1,101 @@
+require 'rails_helper'
+
+RSpec.describe "Storage connected apps", type: :feature do
+ let(:user) { create :user }
+
+ let(:firefox_ua) do
+ "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
+ end
+
+ let(:chrome_ua) do
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
+ "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
+ end
+
+ before do
+ allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
+ allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
+ allow_any_instance_of(User).to receive(:ldap_entry).and_return(
+ uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
+ services_enabled: ["remotestorage"]
+ )
+ login_as user, scope: :user
+ end
+
+ def create_auth(client:, device:, permissions: %w(documents:rw))
+ user.remote_storage_authorizations.create!(
+ permissions: permissions,
+ client_id: client,
+ redirect_uri: "https://#{client}",
+ expire_at: 1.month.from_now,
+ user_agent: device
+ )
+ end
+
+ context "with a connected app" do
+ before do
+ AppCatalog::WebApp.create!(url: "https://notes.example.com", name: "Notes", metadata: {})
+ end
+
+ let!(:auth) do
+ create_auth(client: "notes.example.com", device: firefox_ua,
+ permissions: %w(documents:rw photos:r))
+ end
+
+ after { auth.destroy }
+
+ it "shows the app, its device and permission status" do
+ visit apps_services_storage_path
+
+ expect(page).to have_content("Notes")
+ expect(page).to have_content("notes.example.com")
+ expect(page).to have_content("Firefox on Linux")
+ expect(page).to have_content("2 folders")
+ expect(page).to have_content("Expires")
+ end
+
+ it "renders a details modal with scopes and actions" do
+ visit apps_services_storage_path
+
+ expect(page).to have_selector("li", text: "documents (read/write)", visible: false)
+ expect(page).to have_selector("li", text: "photos (read only)", visible: false)
+ expect(page).to have_link("Launch app", visible: false)
+ expect(page).to have_selector("a", text: "Revoke access", visible: false, count: 1)
+ end
+ end
+
+ context "with duplicates" do
+ let!(:first) { create_auth(client: "notes.example.com", device: firefox_ua) }
+ let!(:second) { create_auth(client: "notes.example.com", device: firefox_ua) }
+ let!(:other) { create_auth(client: "notes.example.com", device: chrome_ua) }
+
+ after do
+ first.destroy
+ second.destroy
+ other.destroy
+ end
+
+ it "groups authorizations for the same app and device into one card" do
+ visit apps_services_storage_path
+
+ expect(page).to have_selector(".rs-auth-card", count: 2)
+ expect(page).to have_content("Firefox on Linux")
+ expect(page).to have_content("Chrome on macOS")
+ expect(page).to have_content("Tokens")
+ end
+
+ it "lists every authorization in the group with its own revoke action" do
+ visit apps_services_storage_path
+
+ expect(page).to have_selector("a", text: "Revoke access", visible: false, count: 3)
+ end
+ end
+
+ context "without connected apps" do
+ it "shows the empty state" do
+ visit apps_services_storage_path
+
+ expect(page).to have_content("No apps connected")
+ end
+ end
+end
diff --git a/spec/models/remote_storage_authorization_spec.rb b/spec/models/remote_storage_authorization_spec.rb
index 1791ab2..620cd41 100644
--- a/spec/models/remote_storage_authorization_spec.rb
+++ b/spec/models/remote_storage_authorization_spec.rb
@@ -311,4 +311,55 @@ RSpec.describe RemoteStorageAuthorization, type: :model do
end
end
end
+ describe "#expired?" do
+ def auth_with_expiry(expire_at)
+ RemoteStorageAuthorization.new(
+ user: user, permissions: %w(documents:rw), client_id: "example.com",
+ redirect_uri: "https://example.com", expire_at: expire_at
+ )
+ end
+
+ it "is false without an expiry" do
+ expect(auth_with_expiry(nil)).not_to be_expired
+ end
+
+ it "is false before the expiry" do
+ expect(auth_with_expiry(1.day.from_now)).not_to be_expired
+ end
+
+ it "is true after the expiry" do
+ expect(auth_with_expiry(1.day.ago)).to be_expired
+ end
+ end
+
+ describe "#device_name" do
+ def auth_with_user_agent(user_agent)
+ RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
+ end
+
+ it "returns nil without a user agent" do
+ expect(auth_with_user_agent(nil).device_name).to be_nil
+ end
+
+ it "recognizes Firefox on Linux" do
+ ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
+ expect(auth_with_user_agent(ua).device_name).to eq("Firefox on Linux")
+ end
+
+ it "recognizes Chrome on macOS" do
+ ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
+ "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
+ expect(auth_with_user_agent(ua).device_name).to eq("Chrome on macOS")
+ end
+
+ it "recognizes Safari on iOS" do
+ ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) " \
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
+ expect(auth_with_user_agent(ua).device_name).to eq("Safari on iOS")
+ end
+
+ it "falls back to the browser when the OS is unknown" do
+ expect(auth_with_user_agent("Firefox/120.0").device_name).to eq("Firefox")
+ end
+ end
end