diff --git a/README.md b/README.md index e9f3cb5e..143257db 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Service for integrating [PlaceOS](https://placeos.com/) with the workplace. These environment variables are required for configuring an instance of Staff API ```console -SG_ENV=production # When set to production, the auth token in the request header will be used for auth, instead of static credentials from environment variables +SG_ENV=production # Database config: PG_DATABASE_URL=postgresql://user:password@hostname/placeos?max_pool_size=5&max_idle_pool_size=5 @@ -19,8 +19,8 @@ PG_DATABASE_URL=postgresql://user:password@hostname/placeos?max_pool_size=5&max_ # Public key for decrypting and validating JWT tokens JWT_PUBLIC=base64-public-key #same one used by PlaceOS rest-api -# Location of PlaceOS API -PLACE_URI=https://example.place.technology +# Redis, signals are published here for drivers and frontends to consume +REDIS_URL=redis://redis:6379 ``` ### Optional @@ -29,19 +29,6 @@ PLACE_URI=https://example.place.technology # Default Timezone STAFF_TIME_ZONE=Australia/Sydney #default to UTC if not provided -# Host header to use when connecting to PlaceOS endpoints -PLACE_HOST_HEADER=example.place.technology - -# Skip SSL verification when connecting to PlaceOS endpoints -SSL_VERIFY_NONE=true - -# Sentry monitoring -SENTRY_DSN= - -# Logstash log ingest -LOGSTASH_HOST=example.com -LOGSTASH_PORT=12345 - # Sentry monitoring SENTRY_DSN= diff --git a/docker-compose.yml b/docker-compose.yml index 47cbbb7f..ca03ddbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,14 @@ services: ports: - 5432:5432 + redis: + image: eqalpha/keydb + healthcheck: + test: ["CMD", "keydb-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + migrator: build: context: ${PWD}/spec/migration @@ -39,13 +47,10 @@ services: depends_on: - migrator - db + - redis security_opt: - seccomp:unconfined environment: PG_DATABASE_URL: postgresql://postgres:password@db:5432/staff_api_test - PLACE_URI: http://toby.dev.place.tech - PLACE_EMAIL: toby@place.technology.com - PLACE_PASSWORD: password - PLACE_AUTH_CLIENT_ID: abc123 - PLACE_AUTH_SECRET: abc123 + REDIS_URL: redis://redis:6379 GITHUB_ACTION: ${GITHUB_ACTION-} diff --git a/shard.lock b/shard.lock index 1a46222b..429aadf3 100644 --- a/shard.lock +++ b/shard.lock @@ -7,7 +7,7 @@ shards: action-controller: git: https://github.com/spider-gazelle/action-controller.git - version: 8.3.0 + version: 8.15.0 active-model: git: https://github.com/spider-gazelle/active-model.git @@ -41,6 +41,10 @@ shards: git: https://github.com/spider-gazelle/connect-proxy.git version: 2.0.5 + crc16: + git: https://github.com/maiha/crc16.cr.git + version: 0.1.0 + cron_parser: git: https://github.com/kostya/cron_parser.git version: 0.4.0 @@ -99,7 +103,7 @@ shards: json-schema: git: https://github.com/spider-gazelle/json-schema.git - version: 1.3.3 + version: 2.1.1 jwt: git: https://github.com/crystal-community/jwt.git @@ -107,7 +111,7 @@ shards: lucky_router: git: https://github.com/luckyframework/lucky_router.git - version: 0.6.1 + version: 0.6.2 neuroplastic: git: https://github.com/spider-gazelle/neuroplastic.git @@ -139,11 +143,7 @@ shards: place_calendar: git: https://github.com/placeos/calendar.git - version: 4.30.0 - - placeos: - git: https://github.com/placeos/crystal-client.git - version: 2.12.3 + version: 4.30.1 placeos-log-backend: git: https://github.com/place-labs/log-backend.git @@ -151,12 +151,24 @@ shards: placeos-models: git: https://github.com/placeos/models.git - version: 9.118.1 + version: 9.119.0 + + pool: + git: https://github.com/ysbaddaden/pool.git + version: 0.2.4 promise: git: https://github.com/spider-gazelle/promise.git version: 3.2.1 + redis: + git: https://github.com/place-labs/crystal-redis.git + version: 2.9.2+git.commit.52e111ee3bbbdc41fbd3509398c08cc0e8e40e59 + + redis-cluster: + git: https://github.com/place-labs/redis-cluster.cr.git + version: 0.8.13 + sanitize: git: https://github.com/straight-shoota/sanitize.git version: 0.1.0+git.commit.c17d933fed22c6d3d615cca91a895166414389af diff --git a/shard.yml b/shard.yml index 2b9ca15a..7100d66b 100644 --- a/shard.yml +++ b/shard.yml @@ -13,9 +13,6 @@ dependencies: placeos-models: github: placeos/models - placeos: - github: placeos/crystal-client - placeos-log-backend: github: place-labs/log-backend version: ">= 0.8.13" @@ -26,6 +23,10 @@ dependencies: promise: github: spider-gazelle/promise + # Redis client used to publish signals (supports clustered redis) + redis-cluster: + github: place-labs/redis-cluster.cr + secrets-env: github: place-labs/secrets-env diff --git a/spec/controllers/booking_approval_permissions_spec.cr b/spec/controllers/booking_approval_permissions_spec.cr index 22774017..140710ef 100644 --- a/spec/controllers/booking_approval_permissions_spec.cr +++ b/spec/controllers/booking_approval_permissions_spec.cr @@ -32,18 +32,6 @@ describe Bookings do owner_headers = Mock::Headers.office365_normal_user(email: "owner@example.com") other_headers = Mock::Headers.office365_normal_user(email: "other@example.com") - # saving a booking spawns signals to the placeos engine, stub the outbound calls - stub_engine = -> do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - end - # an unapproved booking owned by owner@example.com, created by an admin create_pending = -> do status, body = BookingsHelper.http_create_booking( @@ -61,7 +49,6 @@ describe Bookings do describe "approval permissions" do describe "#approve & #reject" do it "forbids users without approval permissions" do - stub_engine.call id = create_pending.call client.post("#{BOOKINGS_BASE}/#{id}/approve", headers: owner_headers).status_code.should eq(403) @@ -76,7 +63,6 @@ describe Bookings do end it "allows zone managers" do - stub_engine.call id = create_pending.call response = client.post("#{BOOKINGS_BASE}/#{id}/approve", headers: manager_headers) @@ -97,7 +83,6 @@ describe Bookings do end it "allows admins" do - stub_engine.call id = create_pending.call response = client.post("#{BOOKINGS_BASE}/#{id}/approve", headers: admin_headers) @@ -108,8 +93,6 @@ describe Bookings do describe "#create" do it "forbids creating an approved or rejected booking without permission" do - stub_engine.call - client.post(BOOKINGS_BASE, headers: owner_headers, body: approval_spec_booking_json(approved: true)).status_code.should eq(403) client.post(BOOKINGS_BASE, headers: owner_headers, body: approval_spec_booking_json(rejected: true)).status_code.should eq(403) client.post(BOOKINGS_BASE, headers: other_headers, body: approval_spec_booking_json(approved: true)).status_code.should eq(403) @@ -118,8 +101,6 @@ describe Bookings do end it "allows users without permission to create pending bookings" do - stub_engine.call - response = client.post(BOOKINGS_BASE, headers: owner_headers, body: approval_spec_booking_json(approved: false, rejected: false)) response.status_code.should eq(201) body = JSON.parse(response.body).as_h @@ -129,8 +110,6 @@ describe Bookings do end it "allows zone managers to create approved or rejected bookings" do - stub_engine.call - response = client.post(BOOKINGS_BASE, headers: manager_headers, body: approval_spec_booking_json(approved: true)) response.status_code.should eq(201) body = JSON.parse(response.body).as_h @@ -149,8 +128,6 @@ describe Bookings do end it "allows admins to create approved bookings" do - stub_engine.call - response = client.post(BOOKINGS_BASE, headers: admin_headers, body: approval_spec_booking_json(approved: true)) response.status_code.should eq(201) body = JSON.parse(response.body).as_h @@ -161,7 +138,6 @@ describe Bookings do describe "#update" do it "forbids the booking owner changing the approval state" do - stub_engine.call id = create_pending.call client.patch("#{BOOKINGS_BASE}/#{id}", headers: owner_headers, body: {approved: true}.to_json).status_code.should eq(403) @@ -177,7 +153,6 @@ describe Bookings do end it "allows the owner to echo back the current approval state" do - stub_engine.call id = create_pending.call client.post("#{BOOKINGS_BASE}/#{id}/approve", headers: manager_headers).status_code.should eq(200) @@ -190,7 +165,6 @@ describe Bookings do end it "forbids the owner resetting an approved booking" do - stub_engine.call id = create_pending.call client.post("#{BOOKINGS_BASE}/#{id}/approve", headers: manager_headers).status_code.should eq(200) @@ -199,7 +173,6 @@ describe Bookings do end it "allows zone managers to approve, reject and reset via update" do - stub_engine.call id = create_pending.call response = client.patch("#{BOOKINGS_BASE}/#{id}", headers: manager_headers, body: {approved: true}.to_json) @@ -229,7 +202,6 @@ describe Bookings do end it "keeps an explicit approval when a manager also changes the booking time" do - stub_engine.call id = create_pending.call response = client.patch("#{BOOKINGS_BASE}/#{id}", headers: manager_headers, body: { diff --git a/spec/controllers/bookings_clash_e2e_spec.cr b/spec/controllers/bookings_clash_e2e_spec.cr index a375df49..53172520 100644 --- a/spec/controllers/bookings_clash_e2e_spec.cr +++ b/spec/controllers/bookings_clash_e2e_spec.cr @@ -13,23 +13,6 @@ describe Bookings do Guest.truncate end - # the create action fans out signals to the engine -- stub them. - # - # NOTE:: these must NOT go in the `Spec.before_each` above. `Spec.before_each` - # is GLOBAL: it runs before every example in the whole suite, so registering - # stubs there shadows stubs other spec files register inside their own - # examples -- `WebMock::StubRegistry#find_stub` returns the FIRST match, and - # these would always be registered first. That silently broke the - # "signal payloads" specs in bookings_spec.cr, whose capturing stub for the - # same channel never matched. - stub_engine = -> do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed").to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed").to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending").to_return(body: "") - end - # a fixed future anchor so the bookings are always valid (not in the past) base = Time.utc(2026, 7, 6, 0, 0, 0) rec_end = base.shift(days: 10).to_unix @@ -39,7 +22,6 @@ describe Bookings do describe "recurring vs a single-occurrence booking" do it "rejects a single booking that clashes with one occurrence of a recurring booking" do - stub_engine.call created = BookingsHelper.http_create_booking( asset_id: "desk-r1", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 11, 0), recurrence_type: "DAILY", recurrence_end: rec_end, timezone: "UTC") @@ -52,7 +34,6 @@ describe Bookings do end it "allows a single booking on the recurring asset at a non-overlapping time" do - stub_engine.call created = BookingsHelper.http_create_booking( asset_id: "desk-r2", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 11, 0), recurrence_type: "DAILY", recurrence_end: rec_end, timezone: "UTC") @@ -65,7 +46,6 @@ describe Bookings do end it "allows a single booking on a different asset at the same time as an occurrence" do - stub_engine.call created = BookingsHelper.http_create_booking( asset_id: "desk-r3", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 11, 0), recurrence_type: "DAILY", recurrence_end: rec_end, timezone: "UTC") @@ -79,7 +59,6 @@ describe Bookings do describe "interleaved recurring bookings" do it "rejects two interleaved recurring bookings on the same asset (partial time overlap)" do - stub_engine.call first = BookingsHelper.http_create_booking( asset_id: "desk-i1", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 12, 0), recurrence_type: "DAILY", recurrence_end: rec_end, timezone: "UTC") @@ -93,7 +72,6 @@ describe Bookings do end it "allows two adjacent recurring bookings on the same asset (no overlap)" do - stub_engine.call first = BookingsHelper.http_create_booking( asset_id: "desk-i2", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 11, 0), recurrence_type: "DAILY", recurrence_end: rec_end, timezone: "UTC") @@ -107,7 +85,6 @@ describe Bookings do end it "allows two recurring bookings on the same asset that fall on different weekdays" do - stub_engine.call monday = base until monday.day_of_week.monday? monday = monday.shift(days: 1) @@ -136,7 +113,6 @@ describe Bookings do perth = Time::Location.load("Australia/Perth") # UTC+8 it "rejects a second overlapping all-day recurring booking that wraps UTC midnight" do - stub_engine.call # Perth-local 00:00 -> 23:59 wraps UTC midnight (16:00 prev day -> 15:59); # this is the row whose inverted time-of-day used to be dropped from the # candidate set, letting a duplicate through. @@ -159,7 +135,6 @@ describe Bookings do end it "allows the second all-day recurring booking on a different desk" do - stub_engine.call b1_start = Time.local(2026, 7, 6, 0, 0, 0, location: perth) b1_end = Time.local(2026, 7, 6, 23, 59, 0, location: perth) perth_rec_end = b1_start.shift(days: 7).to_unix @@ -180,7 +155,6 @@ describe Bookings do describe "interleaved single bookings" do it "rejects a partially overlapping single booking on the same asset" do - stub_engine.call first = BookingsHelper.http_create_booking( asset_id: "desk-s1", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 12, 0)) status.call(first).should eq 201 @@ -191,7 +165,6 @@ describe Bookings do end it "allows an adjacent single booking on the same asset" do - stub_engine.call first = BookingsHelper.http_create_booking( asset_id: "desk-s2", booking_start: at.call(0, 10, 0), booking_end: at.call(0, 11, 0)) status.call(first).should eq 201 diff --git a/spec/controllers/bookings_spec.cr b/spec/controllers/bookings_spec.cr index f4a95dd8..3d53a477 100644 --- a/spec/controllers/bookings_spec.cr +++ b/spec/controllers/bookings_spec.cr @@ -114,13 +114,6 @@ describe Bookings do end it "should update ext data in isolation" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - starting = 5.minutes.from_now.to_unix ending = 40.minutes.from_now.to_unix guest_email = Faker::Internet.email @@ -135,13 +128,6 @@ describe Bookings do end it "should filter by ext data" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - starting = 5.minutes.from_now.to_unix ending = 40.minutes.from_now.to_unix guest_email = Faker::Internet.email @@ -153,13 +139,6 @@ describe Bookings do end it "should filter by multiple ext data" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - guest_email = Faker::Internet.email ext_data = Faker::Lorem.word starting = 5.minutes.from_now.to_unix @@ -210,15 +189,6 @@ describe Bookings do context "[zones/user]" do it "should return a list of bookings filtered by current user" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # user-1, zone-1 booking_one = BookingsHelper.http_create_booking( user_id: "user-1", @@ -291,15 +261,6 @@ describe Bookings do end it "should return a list of bookings filtered by zones" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # user-1, zone-1 booking_one = BookingsHelper.http_create_booking( user_id: "user-1", @@ -347,15 +308,6 @@ describe Bookings do end it "should return a list of bookings filtered by zones and user" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # user-1, zone-1 booking_one = BookingsHelper.http_create_booking( user_id: "user-1", @@ -440,15 +392,6 @@ describe Bookings do context "[group-event]", tags: ["group-event"] do it "should return a list of bookings filtered by current user" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # user-1, zone-1 booking_one = BookingsHelper.http_create_booking( user_id: "user-1", @@ -496,15 +439,6 @@ describe Bookings do end it "should return a list of bookings filtered by zones" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # user-1, zone-1 booking_one = BookingsHelper.http_create_booking( user_id: "user-1", @@ -1165,13 +1099,6 @@ describe Bookings do describe "current_state and history:" do before_each do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - Timecop.scale(600) # 1 second == 10 minutes end @@ -1252,12 +1179,6 @@ describe Bookings do end it "check in early less than an hour before booking start" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant booking = BookingsHelper.create_booking(tenant.id.not_nil!, @@ -1328,12 +1249,6 @@ describe Bookings do end it "cannot check in early more than an hour before booking start" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant tenant.early_checkin = 3600 tenant.save! @@ -1356,12 +1271,6 @@ describe Bookings do it "cannot check in early when another booking is present" do Timecop.scale(1) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant booking = BookingsHelper.create_booking( @@ -1382,12 +1291,6 @@ describe Bookings do end it "cannot check in early on another day" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant booking = BookingsHelper.create_booking(tenant.id.not_nil!, @@ -1488,15 +1391,6 @@ describe Bookings do end it "#create and #update should not allow setting the history" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - booking = BookingsHelper.http_create_booking( booking_start: 5.minutes.from_now.to_unix, booking_end: 15.minutes.from_now.to_unix, @@ -1528,15 +1422,6 @@ describe Bookings do end it "?utm_source= should set booked_from if it is not set" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - body = BookingsHelper.http_create_booking( asset_id: "desk1", booking_type: "desk", @@ -1547,15 +1432,6 @@ describe Bookings do end it "?utm_source= should set source in history" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - Timecop.scale(600) # 1 second == 10 minutes body = BookingsHelper.http_create_booking( @@ -1601,15 +1477,6 @@ describe Bookings do # the state transition it caused, so these check the re-fetched booking rather # than trusting the response of the mutating request. describe "#check_in utm_source history" do - before_each do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - end - after_all do WebMock.reset end @@ -1840,15 +1707,6 @@ describe Bookings do describe "permission", tags: ["auth", "group-event"] do it "#add_attendee should NOT allow adding public or same tenant users to PRIVATE bookings" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", user_email: "user-one@example.com", @@ -1886,15 +1744,6 @@ describe Bookings do end it "#add_attendee should allow adding same tenant users to OPEN bookings" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", user_email: "user-one@example.com", @@ -1933,15 +1782,6 @@ describe Bookings do end it "#add_attendee should allow adding anyone to PUBLIC bookings" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", user_email: "user-one@example.com", @@ -1980,15 +1820,6 @@ describe Bookings do end it "#index should return a list of PUBLIC bookings for unauthenticated users" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # private booking private_booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", @@ -2052,15 +1883,6 @@ describe Bookings do end pending "#index should return a list of OPEN and PUBLIC bookings for same tenant users" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # private booking private_booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", @@ -2124,15 +1946,6 @@ describe Bookings do end it "#index should return a list of PRIVATE, OPEN, and PUBLIC group-event bookings for the booking creator" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # private booking private_booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", @@ -2196,15 +2009,6 @@ describe Bookings do end pending "#index should NOT include attendee details for unauthenticated users" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - # public booking booking = BookingsHelper.http_create_booking( user_id: "user-three@example.com", @@ -2251,15 +2055,6 @@ describe Bookings do end it "#destroy_attendee should allow same tenant users to remove attendees from OPEN bookings" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - booking = BookingsHelper.http_create_booking( user_id: "user-one@example.com", user_email: "user-one@example.com", @@ -2324,12 +2119,6 @@ describe Bookings do end it "#destroy should delete a booking" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant user_email = Faker::Internet.email @@ -2352,12 +2141,6 @@ describe Bookings do end it "#destroy should not change the state of a checked out booking" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant Timecop.scale(600) # 1 second == 10 minutes @@ -2376,12 +2159,6 @@ describe Bookings do end it "#true query param should return deleted bookings" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant user_email = Faker::Internet.email booking1 = BookingsHelper.create_booking(tenant.id.not_nil!, user_email) @@ -2414,15 +2191,6 @@ describe Bookings do end it "#create and #update" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - user_name = Faker::Internet.user_name user_email = Faker::Internet.email starting = Random.new.rand(5..19).minutes.from_now.to_unix @@ -2484,15 +2252,6 @@ describe Bookings do end it "#create and change start and end times" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - user_name = Faker::Internet.user_name user_email = Faker::Internet.email starting = Random.new.rand(5..19).minutes.from_now.to_unix @@ -2561,15 +2320,6 @@ describe Bookings do end it "#cannot double book the same asset" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - user_name = Faker::Internet.user_name user_email = Faker::Internet.email @@ -2599,15 +2349,6 @@ describe Bookings do end it "#cannot double book the same assets (multiple asset ids)" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - user_name = Faker::Internet.user_name user_email = Faker::Internet.email @@ -2638,15 +2379,6 @@ describe Bookings do describe "booking_limits" do before_all do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - Timecop.scale(600) # 1 second == 10 minutes end @@ -2993,15 +2725,6 @@ describe Bookings do end it "#allows a booking once previous has been checked out" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - tenant = get_tenant booking = BookingsHelper.create_booking(tenant.id.not_nil!, 1.minutes.from_now.to_unix, 20.minutes.from_now.to_unix) booking.checked_out_at = 10.minutes.from_now.to_unix @@ -3018,15 +2741,6 @@ describe Bookings do it "prevents checking back in once checked out" do Timecop.scale(1) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - tenant = get_tenant asset_id = "asset-#{Random.new.rand(500)}" booking = BookingsHelper.create_booking(tenant.id.not_nil!, 4.minutes.from_now.to_unix, 20.minutes.from_now.to_unix, asset_id) @@ -3043,13 +2757,6 @@ describe Bookings do end it "prevents checking in after a booking has ended" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - booking = BookingsHelper.http_create_booking( booking_start: 20.minutes.ago.to_unix, booking_end: 5.minutes.ago.to_unix, @@ -3069,12 +2776,6 @@ describe Bookings do end it "#approve & #reject" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant booking = BookingsHelper.create_booking(tenant.id.not_nil!) @@ -3097,12 +2798,6 @@ describe Bookings do end it "#check_in should set checked_in state of a booking" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = Tenant.find_by(domain: "toby.staff-api.dev") booking = BookingsHelper.create_booking(tenant.id.not_nil!) @@ -3114,15 +2809,6 @@ describe Bookings do end it "#create and #update parent child" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - user_name = Faker::Internet.user_name user_email = Faker::Internet.email starting = Random.new.rand(5..19).minutes.from_now.to_unix @@ -3192,15 +2878,6 @@ describe Bookings do context "[visitor-kiosk]", tags: ["visitor-kiosk"] do it "checks in a visitor" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - tenant = get_tenant starting = Random.new.rand(5..19).minutes.from_now.to_unix @@ -3235,15 +2912,6 @@ describe Bookings do end it "#update should correctly change all-day booking to non all-day booking", tags: ["all-day-booking"] do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - user_name = Faker::Internet.user_name user_email = Faker::Internet.email @@ -3292,12 +2960,6 @@ describe Bookings do describe "PPT-2457", tags: "PPT-2457" do it "#check_in should not allow a different user to check out another user's booking" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") tenant = get_tenant Timecop.scale(600) # 1 second == 10 minutes @@ -3328,50 +2990,22 @@ describe Bookings do end describe "signal payloads", tags: "PPT-2375" do - before_each do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - end - it "#create emits staff/booking/changed with action create" do - captured_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return do |request| - captured_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - starting = 5.minutes.from_now.to_unix ending = 40.minutes.from_now.to_unix - client.post(BOOKINGS_BASE, headers: headers, - body: %({"asset_id":"desk-signal-1","booking_start":#{starting},"booking_end":#{ending},"booking_type":"desk"})) - - sleep 50.milliseconds # let spawn fibres run + created = JSON.parse(client.post(BOOKINGS_BASE, headers: headers, + body: %({"asset_id":"desk-signal-1","booking_start":#{starting},"booking_end":#{ending},"booking_type":"desk"})).body) - captured_bodies.size.should be >= 1 - payload = JSON.parse(captured_bodies.last) + signals = SignalSpy.received("placeos/staff/booking/changed", &.["id"].==(created["id"])) + signals.size.should be >= 1 + payload = signals.last.payload payload["action"].should eq "create" payload["booking_start"].should eq starting payload["booking_end"].should eq ending end it "#update with shrunk time window emits action metadata_changed with previous values" do - captured_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return do |request| - captured_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - starting = 5.minutes.from_now.to_unix ending = 90.minutes.from_now.to_unix @@ -3387,11 +3021,10 @@ describe Bookings do client.patch("#{BOOKINGS_BASE}/#{created["id"]}", headers: headers, body: %({"booking_start":#{new_starting},"booking_end":#{new_ending}})) - sleep 50.milliseconds - - # The last captured body should be from the update, not the create - captured_bodies.size.should be >= 2 - update_payload = JSON.parse(captured_bodies.last) + # The last signal should be from the update, not the create + signals = SignalSpy.received("placeos/staff/booking/changed", 2, &.["id"].==(created["id"])) + signals.size.should be >= 2 + update_payload = signals.last.payload update_payload["action"].should eq "metadata_changed" update_payload["booking_start"].should eq new_starting update_payload["booking_end"].should eq new_ending @@ -3400,17 +3033,6 @@ describe Bookings do end it "#update with metadata-only change emits action metadata_changed" do - captured_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return do |request| - captured_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - starting = 5.minutes.from_now.to_unix ending = 40.minutes.from_now.to_unix @@ -3423,24 +3045,14 @@ describe Bookings do client.patch("#{BOOKINGS_BASE}/#{created["id"]}", headers: headers, body: %({"title":"just a title change"})) - sleep 50.milliseconds - - captured_bodies.size.should be >= 2 - update_payload = JSON.parse(captured_bodies.last) + signals = SignalSpy.received("placeos/staff/booking/changed", 2, &.["id"].==(created["id"])) + signals.size.should be >= 2 + update_payload = signals.last.payload update_payload["action"].should eq "metadata_changed" update_payload["title"].should eq "just a title change" end it "#destroy emits staff/booking/changed with action cancelled" do - captured_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return do |request| - captured_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - starting = 5.minutes.from_now.to_unix ending = 40.minutes.from_now.to_unix @@ -3451,10 +3063,9 @@ describe Bookings do client.delete("#{BOOKINGS_BASE}/#{created["id"]}", headers: headers) - sleep 50.milliseconds - - captured_bodies.size.should be >= 2 - destroy_payload = JSON.parse(captured_bodies.last) + signals = SignalSpy.received("placeos/staff/booking/changed", 2, &.["id"].==(created["id"])) + signals.size.should be >= 2 + destroy_payload = signals.last.payload destroy_payload["action"].should eq "cancelled" end end diff --git a/spec/controllers/bookings_visitor_checkout_clash_e2e_spec.cr b/spec/controllers/bookings_visitor_checkout_clash_e2e_spec.cr index c6ce7ec4..3e107461 100644 --- a/spec/controllers/bookings_visitor_checkout_clash_e2e_spec.cr +++ b/spec/controllers/bookings_visitor_checkout_clash_e2e_spec.cr @@ -29,17 +29,6 @@ describe Bookings do client = AC::SpecHelper.client headers = Mock::Headers.office365_guest - # see the NOTE in bookings_clash_e2e_spec.cr -- these must be registered inside - # each example, never in the global `Spec.before_each` - stub_engine = -> do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed").to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed").to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending").to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/checkin").to_return(body: "") - end - # creates a booking for the given visitors, returning the raw response create_visit = ->(booking_type : String, asset_id : String, visitor_emails : Array(String), starting : Int64, ending : Int64) do client.post(BOOKINGS_BASE, headers: headers, body: { @@ -105,7 +94,6 @@ describe Bookings do {"visitor", "room"}.each do |booking_type| {:booking, :guest}.each do |level| it "allows a new #{booking_type} booking over a #{booking_type} booking checked out early (#{level} level check out)" do - stub_engine.call asset_id = "#{booking_type}-slot-#{level}" visit_then_check_out.call(booking_type, asset_id, level) @@ -117,7 +105,6 @@ describe Bookings do # control: proves the second booking really does overlap the first, so the # examples above are exercising clash detection rather than passing trivially it "still rejects a new room booking over a room booking that has not been checked out" do - stub_engine.call asset_id = "room-slot-control" first = create_visit.call("room", asset_id, ["first.visitor@external.com"], 5.minutes.ago.to_unix, 25.minutes.from_now.to_unix) @@ -135,7 +122,6 @@ describe Bookings do visitor_b = "visitor.b@external.com" it "keeps the booking active until the last visitor on site checks out" do - stub_engine.call asset_id = "room-slot-multi" booking_id = first_room_visit.call(asset_id, [visitor_a, visitor_b]) @@ -153,7 +139,6 @@ describe Bookings do end it "releases the booking when the only visitor who arrived checks out" do - stub_engine.call asset_id = "room-slot-partial" booking_id = first_room_visit.call(asset_id, [visitor_a, visitor_b]) @@ -166,7 +151,6 @@ describe Bookings do end it "releases the booking when a visitor who never arrived is checked out" do - stub_engine.call asset_id = "room-slot-no-show" booking_id = first_room_visit.call(asset_id, [visitor_a]) @@ -177,7 +161,6 @@ describe Bookings do end it "checks every visitor out when the host checks out of the meeting" do - stub_engine.call asset_id = "room-slot-host-checkout" booking_id = first_room_visit.call(asset_id, [visitor_a, visitor_b]) @@ -190,7 +173,6 @@ describe Bookings do end it "does not check out a visitor who never arrived when the host checks out" do - stub_engine.call asset_id = "room-slot-host-checkout-no-show" booking_id = first_room_visit.call(asset_id, [visitor_a, visitor_b]) diff --git a/spec/controllers/calendars_spec.cr b/spec/controllers/calendars_spec.cr index 1e7a8b9a..de86be7a 100644 --- a/spec/controllers/calendars_spec.cr +++ b/spec/controllers/calendars_spec.cr @@ -47,10 +47,7 @@ describe Calendars do it "#free_busy should return free busy data of calendars" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendars") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=zone-EzcsmWbvUG6") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["zone-EzcsmWbvUG6"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/getSchedule") .to_return(body: File.read("./spec/fixtures/events/o365/get_schedule.json")) WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") @@ -70,10 +67,7 @@ describe Calendars do it "#free_busy request with a duration < 30mins" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendars") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=zone-EzcsmWbvUG6") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["zone-EzcsmWbvUG6"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/getSchedule") .to_return(body: File.read("./spec/fixtures/events/o365/get_schedule.json")) WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") @@ -92,10 +86,7 @@ describe Calendars do it "#free_busy should not allow an interval of less than 5 minutes" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendars") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=zone-EzcsmWbvUG6") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["zone-EzcsmWbvUG6"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/getSchedule") .to_return(body: File.read("./spec/fixtures/events/o365/get_schedule.json")) WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") @@ -154,20 +145,25 @@ CALENDARS_BASE = Calendars.base_route module CalendarsHelper extend self + # how a system is serialised in a response + def system_json(id : String) : JSON::Any + JSON.parse(PlaceOS::Model::ControlSystem.find!(id).to_json) + end + def calendar_list_output [{"id" => "dev@acaprojects.com"}, - {"id" => "room1@example.com", "system" => {"created_at" => 1562041110, "updated_at" => 1562041120, "id" => "sys-rJQQlR4Cn7", "name" => "Room 1", "zones" => ["zone-rGhCRp_aUD"], "modules" => ["mod-rJRCVYKVuB", "mod-rJRGK21pya", "mod-rJRHYsZExU"], "email" => "room1@example.com", "capacity" => 10, "features" => [] of String, "bookable" => true, "installed_ui_devices" => 0, "version" => 5}}, - {"id" => "room2@example.com", "system" => {"created_at" => 1562041110, "updated_at" => 1562041120, "id" => "sys-rHQQlR4Cn7", "name" => "Room 2", "zones" => ["zone-rGfCRp_aUD"], "modules" => ["mod-rJRCVFDVuB", "mod-rJRWE21pya", "mod-rJBGYsZExU"], "email" => "room2@example.com", "capacity" => 10, "features" => [] of String, "bookable" => true, "installed_ui_devices" => 0, "version" => 5}}] + {"id" => "room1@example.com", "system" => system_json("sys-rJQQlR4Cn7")}, + {"id" => "room2@example.com", "system" => system_json("sys-rHQQlR4Cn7")}] end def free_busy_output [{"id" => "jon@example.com", "availability" => [{"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}]}, - {"id" => "room1@example.com", "system" => {"created_at" => 1562041110, "updated_at" => 1562041120, "id" => "sys-rJQQlR4Cn7", "name" => "Room 1", "zones" => ["zone-rGhCRp_aUD"], "modules" => ["mod-rJRCVYKVuB", "mod-rJRGK21pya", "mod-rJRHYsZExU"], "email" => "room1@example.com", "capacity" => 10, "features" => [] of String, "bookable" => true, "installed_ui_devices" => 0, "version" => 5}, "availability" => [{"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}, {"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552680000, "timezone" => "America/Los_Angeles"}, {"status" => "busy", "starts_at" => 1552680000, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}]}] + {"id" => "room1@example.com", "system" => system_json("sys-rJQQlR4Cn7"), "availability" => [{"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}, {"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552680000, "timezone" => "America/Los_Angeles"}, {"status" => "busy", "starts_at" => 1552680000, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}]}] end def free_busy_output_15min [{"id" => "jon@example.com", "availability" => [{"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}]}, - {"id" => "room1@example.com", "system" => {"created_at" => 1562041110, "updated_at" => 1562041120, "id" => "sys-rJQQlR4Cn7", "name" => "Room 1", "zones" => ["zone-rGhCRp_aUD"], "modules" => ["mod-rJRCVYKVuB", "mod-rJRGK21pya", "mod-rJRHYsZExU"], "email" => "room1@example.com", "capacity" => 10, "features" => [] of String, "bookable" => true, "installed_ui_devices" => 0, "version" => 5}, "availability" => [{"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}, {"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552680000, "timezone" => "America/Los_Angeles"}]}] + {"id" => "room1@example.com", "system" => system_json("sys-rJQQlR4Cn7"), "availability" => [{"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552683600, "timezone" => "America/Los_Angeles"}, {"status" => "busy", "starts_at" => 1552676400, "ends_at" => 1552680000, "timezone" => "America/Los_Angeles"}]}] end def stub_cal_endpoints @@ -175,25 +171,14 @@ module CalendarsHelper .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendars") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=zone-EzcsmWbvUG6") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/getSchedule") .to_return(body: File.read("./spec/fixtures/events/o365/get_schedule_avail.json")) - WebMock.stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/sys-rJQQlR4Cn7") - .to_return(body: File.read("./spec/fixtures/placeos/systemJ.json")) - - WebMock.stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/sys-rHQQlR4Cn7") - .to_return(body: File.read("./spec/fixtures/placeos/systemH.json")) + SystemsHelper.load_fixture("systemJ.json") + SystemsHelper.load_fixture("systemH.json") WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events") .to_return(body: File.read("./spec/fixtures/events/o365/create.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev@acaprojects.com/calendar/events/AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA=") .to_return(body: File.read("./spec/fixtures/events/o365/create.json")) WebMock.stub(:patch, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/") diff --git a/spec/controllers/events_spec.cr b/spec/controllers/events_spec.cr index 8d84aec0..518adf1c 100644 --- a/spec/controllers/events_spec.cr +++ b/spec/controllers/events_spec.cr @@ -19,8 +19,7 @@ describe Events, tags: ["event"] do it "#index should return a list of events with metadata" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["z1"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) @@ -36,8 +35,6 @@ describe Events, tags: ["event"] do end it "metadata extension endpoint should filter by extension data" do - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) @@ -57,10 +54,6 @@ describe Events, tags: ["event"] do it "#index should return a list of events with metadata of master event if event in list is an occurrence" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=zone-EzcsmWbvUG6") - .to_return(body: File.read("./spec/fixtures/placeos/systemJ.json")) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index_with_recurring_event.json")) @@ -78,10 +71,6 @@ describe Events, tags: ["event"] do it "#index should skip events with missing ical_uid and return the rest" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=zone-EzcsmWbvUG6") - .to_return(body: File.read("./spec/fixtures/placeos/systemJ.json")) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index_missing_ical_uid.json")) @@ -99,8 +88,7 @@ describe Events, tags: ["event"] do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendars") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["z1"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) @@ -266,7 +254,6 @@ describe Events, tags: ["event"] do req_body = EventsHelper.update_event_input system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) updated_event = client.patch("#{EVENTS_BASE}/#{created_event["id"]}?system_id=#{system_id}", headers: headers, body: req_body).body updated_event.includes?(%(some updated notes)) # .should eq(EventsHelper.update_event_output) @@ -359,7 +346,6 @@ describe Events, tags: ["event"] do # Update req_body = EventsHelper.update_event_input system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) resp = client.patch("#{EVENTS_BASE}/#{created_event["id"]}?system_id=#{system_id}", headers: headers, body: req_body) updated_event = JSON.parse(resp.body) @@ -395,7 +381,6 @@ describe Events, tags: ["event"] do .to_return(EventsHelper.event_query_response(event_id)) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # public user no_auth_headers = Mock::Headers.office365_no_auth @@ -451,7 +436,6 @@ describe Events, tags: ["event"] do .to_return(EventsHelper.event_query_response(event_id)) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # public user no_auth_headers = Mock::Headers.office365_no_auth @@ -510,7 +494,6 @@ describe Events, tags: ["event"] do .to_return(EventsHelper.event_query_response(event_id)) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # public user no_auth_headers = Mock::Headers.office365_no_auth @@ -574,7 +557,6 @@ describe Events, tags: ["event"] do .to_return(EventsHelper.event_query_response(event_id)) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # Default: existing attendees are not notified, so only the attendees # property is sent upstream. @@ -630,7 +612,6 @@ describe Events, tags: ["event"] do .to_return(EventsHelper.event_query_response(event_id)) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # public user should NOT be able to delete attendees no_auth_headers = Mock::Headers.office365_no_auth @@ -681,7 +662,6 @@ describe Events, tags: ["event"] do .to_return(EventsHelper.event_query_response(event_id)) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # public user should NOT be able to delete attendees no_auth_headers = Mock::Headers.office365_no_auth @@ -709,8 +689,7 @@ describe Events, tags: ["event"] do pending "#index should return a list of PUBLIC events for unauthenticated users" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["z1"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) @@ -757,8 +736,7 @@ describe Events, tags: ["event"] do pending "#index should return a list of OPEN and PUBLIC events for same tenant users" do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") - .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + SystemsHelper.load_fixture("systems.json", zones: ["z1"]) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) @@ -840,11 +818,6 @@ describe Events, tags: ["event"] do end it "details for event with guest access and event is recurring instance" do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/events/o365/show_recurring.json")) WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events") @@ -1000,20 +973,6 @@ describe Events, tags: ["event"] do meta = metadata.not_nil! - WebMock.stub(:get, "http://toby.dev.place.tech/api/engine/v2/metadata/sys-rJQQlR4Cn7?name=permissions") - .to_return(body: %({"permissions": - {"name":"permissions", - "parent_id": "22", - "description" : "grant access", - "details":{"admin": ["admin"]}}})) - - WebMock.stub(:get, "http://toby.dev.place.tech/api/engine/v2/metadata/zone-rGhCRp_aUD?name=permissions") - .to_return(body: %({"permissions": - {"name":"permissions", - "parent_id": "22", - "description" : "grant access", - "details":{"admin": ["admin"]}}})) - # delete resp = client.delete("#{EVENTS_BASE}/#{created_event_id}?system_id=#{meta.try &.system_id}", headers: headers) resp.success?.should be_true @@ -1044,7 +1003,6 @@ describe Events, tags: ["event"] do # ensure the user has permissions to update the event system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # approve WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/room1%40example.com/calendar/events/AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA%3D/accept") @@ -1079,7 +1037,6 @@ describe Events, tags: ["event"] do .to_return(body: {success: true}.to_json) system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) resp = client.post("#{EVENTS_BASE}/#{created_event["id"]}/reject?system_id=#{system_id}", headers: headers) resp.success?.should eq true end @@ -1091,9 +1048,6 @@ describe Events, tags: ["event"] do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/room1%40example.com/calendar/events/AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA%3D") .to_return(body: File.read("./spec/fixtures/events/o365/create.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/checkin") - .to_return(body: "") - # Create event req_body = EventsHelper.create_event_input @@ -1140,9 +1094,6 @@ describe Events, tags: ["event"] do WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev@acaprojects.com/calendar/events/event_instance_of_recurrence_id") .to_return(body: File.read("./spec/fixtures/events/o365/show_recurring.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/checkin") - .to_return(body: "") - WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/room1%40example.com/calendar/events/event_instance_of_recurrence_id") .to_return(body: File.read("./spec/fixtures/events/o365/show_instance_recurring.json")) @@ -1278,7 +1229,6 @@ describe Events, tags: ["event"] do # ensure the user has permissions to update the event system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) # approve WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/room1%40example.com/calendar/events/#{URI.encode_path(created_event_id)}/accept") @@ -1569,28 +1519,7 @@ describe Events, tags: ["event"] do end describe "signal payloads", tags: "PPT-2375" do - before_each do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - end - it "#notify_change emits staff/event/changed signal when only the host changes" do - # Reset and re-register stubs so our capturing stub is registered first - # (WebMock uses the first matching stub, not the last) - WebMock.reset - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - - # Capture the signal - captured_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return do |request| - captured_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - # Create a ControlSystem in the database system_id = "sys-host-change-test" # Cleanup any leftover from a previous run @@ -1638,36 +1567,24 @@ describe Events, tags: ["event"] do ) resp.status_code.should eq(202) - sleep 100.milliseconds # let spawn fibres run - # Signal MUST be emitted because the host changed - captured_bodies.size.should be >= 1 - payload = JSON.parse(captured_bodies.last) + signals = SignalSpy.received("placeos/staff/event/changed", &.["system_id"]?.==(system_id)) + signals.size.should be >= 1 + payload = signals.last.payload payload["action"].as_s.should eq "update" payload["previous_host_email"].as_s.should eq initial_host payload["host"].as_s.should eq new_host end it "#notify_change ignores the stale room-mailbox echo but never the master copy" do - WebMock.reset - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - system_id = "sys-mirror-echo-test" # Signals are emitted from spawned fibres, so one belonging to an earlier - # example can still be in flight when this one installs its stub and be - # captured here. This example asserts on exact counts, so it records only - # the signals it caused -- identified by its own dedicated system. - captured_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return do |request| - body = request.body.try(&.gets_to_end) || "" - captured_bodies << body if JSON.parse(body)["system_id"]?.try(&.as_s?) == system_id - HTTP::Client::Response.new(200, body: "") - end + # example can still be in flight when this one starts and be received + # here. This example asserts on exact counts, so it counts only the + # signals it caused -- identified by its own dedicated system. + channel = "placeos/staff/event/changed" + ours = ->(payload : JSON::Any) { payload["system_id"]? == system_id } PlaceOS::Model::ControlSystem.find?(system_id).try(&.delete) test_system = PlaceOS::Model::Generator.control_system @@ -1706,18 +1623,16 @@ describe Events, tags: ["event"] do # 1) Organizer/master copy moves the event Wed -> Thu: a real change. client.post("#{EVENTS_BASE}/notify/updated/#{system_id}/#{master_event_id}", headers: headers, body: event_body.call(master_event_id, thu, thu_end)).status_code.should eq(202) - sleep 100.milliseconds - - captured_bodies.size.should eq 1 - first = JSON.parse(captured_bodies.last) + signals = SignalSpy.received(channel, &ours) + signals.size.should eq 1 + first = signals.last.payload first["event_start"].as_i64.should eq thu first["previous_event_start"].as_i64.should eq wed # A signal from an earlier example's spawned fibre can still be in flight # and land here. Stand one in deliberately: the counts below are only # meaningful if a signal for another system is not mistaken for ours. - HTTP::Client.post("#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed", - body: %({"system_id": "sys-unrelated-example", "event_id": "evt-unrelated"})) + SignalSpy.publish(channel, {system_id: "sys-unrelated-example", event_id: "evt-unrelated"}) # 2) Room copy lags and reports the OLD time (Wed): the stale echo that must # now be ignored. @@ -1725,17 +1640,16 @@ describe Events, tags: ["event"] do headers: headers, body: event_body.call(room_event_id, wed, wed_end)).status_code.should eq(202) sleep 100.milliseconds - captured_bodies.size.should eq 1 # no reversed signal emitted - EventMetadata.find_by(ical_uid: ical_uid).event_start.should eq thu # record not corrupted + SignalSpy.received(channel, 2, wait: 0.seconds, &ours).size.should eq 1 # no reversed signal emitted + EventMetadata.find_by(ical_uid: ical_uid).event_start.should eq thu # record not corrupted # 3) A further master-copy change (Thu -> Fri) within the window is a real # edit and must still emit — the guard only targets mirror copies. client.post("#{EVENTS_BASE}/notify/updated/#{system_id}/#{master_event_id}", headers: headers, body: event_body.call(master_event_id, fri, fri_end)).status_code.should eq(202) - sleep 100.milliseconds - - captured_bodies.size.should eq 2 - last = JSON.parse(captured_bodies.last) + signals = SignalSpy.received(channel, 2, &ours) + signals.size.should eq 2 + last = signals.last.payload last["event_start"].as_i64.should eq fri last["previous_event_start"].as_i64.should eq thu end @@ -1751,25 +1665,15 @@ describe Events, tags: ["event"] do # an attendee of the event, used where the requester must not be the host requester = "jon@example.com" - # Creates an event and returns {event_id, staff/event/changed bodies, - # staff/guest/attending bodies, bodies PATCHed upstream}. Everything is - # captured from here on; WebMock matches the first stub registered, so these - # have to go in ahead of the shared ones. + changed = "placeos/staff/event/changed" + attending = "placeos/staff/guest/attending" + + # Creates an event and returns {event_id, bodies PATCHed upstream}. + # Signals and PATCHes are captured from here on; WebMock matches the first + # stub registered, so the PATCH stub has to go in ahead of the shared ones. create_event = ->(one_off : Bool) do WebMock.reset - changed_bodies = [] of String - attending_bodies = [] of String patch_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return do |request| - changed_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return do |request| - attending_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end WebMock.stub(:patch, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA%3D") .to_return do |request| patch_bodies << (request.body.try(&.gets_to_end) || "") @@ -1779,20 +1683,19 @@ describe Events, tags: ["event"] do EventsHelper.stub_one_off_event if one_off EventsHelper.stub_event_tokens EventsHelper.stub_update_endpoints - EventsHelper.stub_permissions_check(system_id) created = JSON.parse(client.post(EVENTS_BASE, headers: headers, body: EventsHelper.create_event_input).body).as_h event_id = created["id"].to_s EventsHelper.stub_room_event_query(event_id) - changed_bodies.clear - attending_bodies.clear + sleep 100.milliseconds # the create's signals are not part of the example + SignalSpy.clear patch_bodies.clear - {event_id, changed_bodies, attending_bodies, patch_bodies} + {event_id, patch_bodies} end it "reports extension_data.host_override as the host" do - event_id, changed_bodies, _, _ = create_event.call(false) + event_id, _ = create_event.call(false) new_host = "new-host@example.com" resp = client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, @@ -1800,25 +1703,25 @@ describe Events, tags: ["event"] do resp.status_code.should eq(200) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq new_host payload["previous_host_email"].as_s.should eq organiser # the mailbox the event actually lives on is still reported payload["organiser_email"].as_s.should eq organiser # a later edit that does not touch the host is not a reassignment - changed_bodies.clear + SignalSpy.clear client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq new_host payload["previous_host_email"].as_s.should eq new_host end it "moves the meeting to the new host's calendar when the host field changes" do - event_id, changed_bodies, _, _ = create_event.call(true) + event_id, _ = create_event.call(true) new_host = "another-host@example.com" EventsHelper.stub_calendar_write_access(new_host) @@ -1860,14 +1763,14 @@ describe Events, tags: ["event"] do after.ical_uid.should eq "ical-moved-001" after.attendees.to_a.size.should eq before.attendees.to_a.size - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq new_host payload["organiser_email"].as_s.should eq new_host payload["previous_host_email"].as_s.should eq organiser end it "keeps the previous host in the moved meeting when the attendees sent still list them" do - event_id, _, _, _ = create_event.call(true) + event_id, _ = create_event.call(true) new_host = "another-host@example.com" EventsHelper.stub_calendar_write_access(new_host) WebMock.stub(:delete, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/#{URI.encode_path_segment(event_id)}") @@ -1890,7 +1793,7 @@ describe Events, tags: ["event"] do end it "supersedes a reassignment when the meeting moves to the new host" do - event_id, changed_bodies, _, _ = create_event.call(true) + event_id, _ = create_event.call(true) # a meeting hosted by someone other than the mailbox that owns it stand_in = "stand-in-host@example.com" @@ -1908,7 +1811,7 @@ describe Events, tags: ["event"] do WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(new_host)}/calendar/events") .to_return(body: EventsHelper.mock_event_id(moved_event_id, "ical-superseded", recurring: false, organizer: new_host).to_json) - changed_bodies.clear + SignalSpy.clear client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, body: EventsHelper.reassign_host_input(host: new_host)).status_code.should eq(200) sleep 100.milliseconds @@ -1919,13 +1822,13 @@ describe Events, tags: ["event"] do host_override_of.call(moved).should be_nil # and the person who was actually hosting is the one told they no longer are - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq new_host payload["previous_host_email"].as_s.should eq stand_in end it "does not move the meeting when the host field repeats the organiser" do - event_id, changed_bodies, _, _ = create_event.call(false) + event_id, _ = create_event.call(false) WebMock.stub(:delete, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/#{URI.encode_path_segment(event_id)}") .to_return { raise "the meeting must not be cancelled" } @@ -1934,7 +1837,7 @@ describe Events, tags: ["event"] do body: EventsHelper.reassign_host_input(host: organiser)).status_code.should eq(200) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq organiser payload["previous_host_email"].as_s.should eq organiser end @@ -1943,7 +1846,6 @@ describe Events, tags: ["event"] do WebMock.reset EventsHelper.stub_event_tokens EventsHelper.stub_update_endpoints - EventsHelper.stub_permissions_check(system_id) created = JSON.parse(client.post(EVENTS_BASE, headers: headers, body: EventsHelper.create_recurring_event_input).body).as_h recurring_id = created["id"].to_s @@ -1961,7 +1863,7 @@ describe Events, tags: ["event"] do end it "ignores a host field that has fallen back to the requesting user" do - event_id, changed_bodies, _, _ = create_event.call(false) + event_id, _ = create_event.call(false) # an attendee edits the meeting; front ends fall back to the current user # when they cannot resolve the organiser, which must not steal the event @@ -1970,31 +1872,31 @@ describe Events, tags: ["event"] do body: EventsHelper.reassign_host_input(host: requester)).status_code.should eq(200) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq organiser payload["previous_host_email"].as_s.should eq organiser end it "clears the reassignment when the override is emptied" do - event_id, changed_bodies, _, _ = create_event.call(false) + event_id, _ = create_event.call(false) new_host = "temporary-host@example.com" client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) sleep 100.milliseconds - changed_bodies.clear + SignalSpy.clear client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, body: EventsHelper.reassign_host_input(host_override: "")).status_code.should eq(200) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq organiser payload["previous_host_email"].as_s.should eq new_host end it "keeps the reassignment, and does not re-invite anyone, when the event changes room" do - event_id, changed_bodies, attending_bodies, _ = create_event.call(false) + event_id, _ = create_event.call(false) new_host = "moved-host@example.com" client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, @@ -2002,19 +1904,15 @@ describe Events, tags: ["event"] do sleep 100.milliseconds # the room the event moves to - systems = Array(JSON::Any).from_json(File.read("./spec/fixtures/placeos/systems.json")).map &.to_json + SystemsHelper.load_fixture("systems.json") moved_system_id = "sys_id" - WebMock.stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/#{moved_system_id}") - .to_return(body: systems[1]) - EventsHelper.stub_permissions_check(moved_system_id) - changed_bodies.clear - attending_bodies.clear + SignalSpy.clear client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, body: EventsHelper.reassign_host_input(host_override: new_host, attendee: requester, system_id: moved_system_id)).status_code.should eq(200) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["previous_system_id"].as_s.should eq system_id payload["system_id"].as_s.should eq moved_system_id # a move must not read as a reassignment back to the organiser @@ -2022,11 +1920,11 @@ describe Events, tags: ["event"] do payload["previous_host_email"].as_s.should eq new_host # the visitor was already attending — the move is a change, not an invite - attending_bodies.map { |body| JSON.parse(body)["attendee_email"].as_s }.should_not contain requester + SignalSpy.payloads(attending).map(&.["attendee_email"].as_s).should_not contain requester end it "puts the new host in the meeting so it reaches their calendar" do - event_id, _, _, patch_bodies = create_event.call(false) + event_id, patch_bodies = create_event.call(false) new_host = "not-invited@example.com" client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, @@ -2039,26 +1937,20 @@ describe Events, tags: ["event"] do end it "drops a reassignment when the calendar reports a different organiser" do - event_id, changed_bodies, _, _ = create_event.call(false) + event_id, _ = create_event.call(false) new_host = "placeos-host@example.com" client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) sleep 100.milliseconds - JSON.parse(changed_bodies.last)["host"].as_s.should eq new_host + SignalSpy.received(changed).last.payload["host"].as_s.should eq new_host # Office365 owns the organiser, so a change there wins: recreating the # meeting under someone else must not leave the old reassignment in place - changed_bodies.clear + SignalSpy.clear outlook_organiser = "outlook-organiser@example.com" meta = EventMetadata.find_by(event_id: event_id) - # the webhook route resolves the system from the database - PlaceOS::Model::ControlSystem.find?(system_id).try(&.delete) - webhook_system = PlaceOS::Model::Generator.control_system - webhook_system.id = system_id - webhook_system.save! - client.post("#{EVENTS_BASE}/notify/updated/#{system_id}/#{event_id}", headers: headers, body: %({ "event_start": #{meta.event_start}, "event_end": #{meta.event_end}, @@ -2071,7 +1963,7 @@ describe Events, tags: ["event"] do })).status_code.should eq(202) sleep 100.milliseconds - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received(changed).last.payload payload["host"].as_s.should eq outlook_organiser payload["organiser_email"].as_s.should eq outlook_organiser # the person who was hosting is told they no longer are @@ -2080,7 +1972,7 @@ describe Events, tags: ["event"] do end it "announces new visitors as attending the reassigned host" do - event_id, _, attending_bodies, _ = create_event.call(false) + event_id, _ = create_event.call(false) new_host = "new-host@example.com" client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, @@ -2089,9 +1981,9 @@ describe Events, tags: ["event"] do # the visitor mailer skips emailing the host their own invite, which only # works when the reassignment is reflected here too - guest = attending_bodies.map { |body| JSON.parse(body) }.find { |body| body["attendee_email"].as_s == "guest@external.com" } + guest = SignalSpy.received(attending, &.["attendee_email"].==("guest@external.com")).first? guest.should_not be_nil - guest.not_nil!["host"].as_s.should eq new_host + guest.not_nil!.payload["host"].as_s.should eq new_host end end @@ -2100,38 +1992,22 @@ describe Events, tags: ["event"] do describe "room moves", tags: "PPT-2375" do it "keeps the meeting's metadata and does not re-invite anyone" do WebMock.reset - attending_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return do |request| - attending_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end - changed_bodies = [] of String - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return do |request| - changed_bodies << (request.body.try(&.gets_to_end) || "") - HTTP::Client::Response.new(200, body: "") - end EventsHelper.stub_event_tokens EventsHelper.stub_update_endpoints system_id = "sys-rJQQlR4Cn7" - EventsHelper.stub_permissions_check(system_id) created = JSON.parse(client.post(EVENTS_BASE, headers: headers, body: EventsHelper.create_event_input).body).as_h event_id = created["id"].to_s EventsHelper.stub_room_event_query(event_id) # the room the event moves to - systems = Array(JSON::Any).from_json(File.read("./spec/fixtures/placeos/systems.json")).map &.to_json + SystemsHelper.load_fixture("systems.json") moved_system_id = "sys_id" - WebMock.stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/#{moved_system_id}") - .to_return(body: systems[1]) - EventsHelper.stub_permissions_check(moved_system_id) before = EventMetadata.find_by(event_id: event_id) before.system_id.should eq system_id - attending_bodies.clear - changed_bodies.clear + sleep 100.milliseconds # the create's signals are not part of the example + SignalSpy.clear # the same guest list as the meeting was created with, moved to another room body = EventsHelper.create_event_input.gsub(%("system_id": "#{system_id}"), %("system_id": "#{moved_system_id}")) @@ -2146,10 +2022,10 @@ describe Events, tags: ["event"] do after.attendees.to_a.size.should eq before.attendees.to_a.size # the move is a change, not an invitation - payload = JSON.parse(changed_bodies.last) + payload = SignalSpy.received("placeos/staff/event/changed").last.payload payload["previous_system_id"].as_s.should eq system_id payload["system_id"].as_s.should eq moved_system_id - attending_bodies.map { |signal| JSON.parse(signal)["attendee_email"].as_s }.should_not contain "jon@example.com" + SignalSpy.payloads("placeos/staff/guest/attending").map(&.["attendee_email"].as_s).should_not contain "jon@example.com" end end end diff --git a/spec/controllers/guests_spec.cr b/spec/controllers/guests_spec.cr index 96c4e75c..263aa64c 100644 --- a/spec/controllers/guests_spec.cr +++ b/spec/controllers/guests_spec.cr @@ -3,9 +3,6 @@ require "./helpers/booking_helper" require "../../src/constants" describe Guests do - systems_json = File.read("./spec/fixtures/placeos/systems.json") - systems_resp = Array(JSON::Any).from_json(systems_json).map &.to_json - client = AC::SpecHelper.client headers = Mock::Headers.office365_guest @@ -90,17 +87,11 @@ describe Guests do pending "should return guests visiting today in a subset of rooms and bookings" do WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) - {"sys-rJQQlR4Cn7", "sys_id"}.each_with_index do |system_id, index| - WebMock - .stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/#{system_id}") - .to_return(body: systems_resp[index]) - end + SystemsHelper.load_fixture("systems.json") WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.com/calendar?") .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) tenant = get_tenant guest = GuestsHelper.create_guest(tenant.id, "Toby", "toby23@redant.com.au") @@ -277,31 +268,6 @@ describe Guests do it "#meetings should show meetings for guest" do WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - - WebMock.stub(:any, /^http:\/\/.*\/api\/engine\/v2\/systems\//).to_return(body: %({ - "name": "Room #{Random.rand(99)}", - "description": null, - "email": "email-#{Random.rand(99)}@example.com", - "capacity": 10, - "features": [], - "bookable": true, - "installed_ui_devices": 0, - "zones": [ - "zone-#{Random.rand(99)}" - ], - "modules": [ - "mod-rJRJ#{Random.rand(99)}", - "mod-rJRL#{Random.rand(99)}", - "mod-rJR#{Random.rand(99)}" - ], - "created_at": 1562041127, - "updated_at": 1562041137, - "support_url": null, - "version": 5, - "id": "sys_id-#{Random.rand(99)}" - })) WebMock.stub(:get, /^https:\/\/graph\.microsoft\.com\/v1\.0\/users\/[^\/]*\/calendar\/calendarView\?startDateTime.*/) .to_return(GuestsHelper.mock_event_query_json) @@ -310,6 +276,7 @@ describe Guests do guest = GuestsHelper.create_guest(tenant.id) meta = EventMetadatasHelper.create_event(tenant.id, "generic_event") + SystemsHelper.system(id: meta.system_id.not_nil!, email: "email-#{Random.rand(99)}@example.com", capacity: 10) WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/#{URI.encode_www_form(meta.host_email)}/calendar/events/generic_event") .to_return(body: File.read("./spec/fixtures/events/o365/generic_event.json")) diff --git a/spec/controllers/helpers/event_helper.cr b/spec/controllers/helpers/event_helper.cr index 1e703d0a..fe141de1 100644 --- a/spec/controllers/helpers/event_helper.cr +++ b/spec/controllers/helpers/event_helper.cr @@ -4,16 +4,7 @@ module EventsHelper def stub_event_tokens WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - systems_json = File.read("./spec/fixtures/placeos/systems.json") - systems_resp = Array(JSON::Any).from_json(systems_json).map &.to_json - WebMock.stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/sys-rJQQlR4Cn7") - .to_return(body: systems_resp[0]) + SystemsHelper.load_fixture("systemJ.json") end def stub_create_endpoints @@ -148,32 +139,6 @@ module EventsHelper .to_return(body: %({"value": [#{event}]})) end - def stub_permissions_check(system_id) - WebMock.stub(:get, "http://toby.dev.place.tech/api/engine/v2/metadata/#{system_id}?name=permissions") - .to_return(body: %({ - "permissions": { - "name": "permissions", - "description": "", - "parent_id": "#{system_id}", - "details": { - "admin": ["#{system_id}", "admin"] - } - } - })) - - WebMock.stub(:get, "http://toby.dev.place.tech/api/engine/v2/metadata/zone-rGhCRp_aUD?name=permissions") - .to_return(body: %({ - "permissions": { - "name": "permissions", - "description": "", - "parent_id": "zone-rGhCRp_aUD", - "details": { - "admin": ["#{system_id}", "admin"] - } - } - })) - end - def event_query_response(id, ical = nil) { "value" => [EventsHelper.mock_event_id(id, ical)], diff --git a/spec/controllers/helpers/placeos_helper.cr b/spec/controllers/helpers/placeos_helper.cr new file mode 100644 index 00000000..65c4962a --- /dev/null +++ b/spec/controllers/helpers/placeos_helper.cr @@ -0,0 +1,124 @@ +require "redis" + +# Captures the signals staff-api publishes to redis +module SignalSpy + extend self + + record Signal, channel : String, payload : JSON::Any + + @@signals = [] of Signal + @@lock = Mutex.new + @@started = false + + # Subscribes to every placeos channel for the remainder of the suite + def start : Nil + return if @@started + @@started = true + + ready = Channel(Nil).new + spawn do + Redis.new(url: App::REDIS_URL).psubscribe("placeos/*") do |subscription| + subscription.psubscribe { |_pattern, _count| ready.send(nil) } + subscription.pmessage do |_pattern, channel, message| + @@lock.synchronize { @@signals << Signal.new(channel, JSON.parse(message)) } + end + end + end + + select + when ready.receive + when timeout(5.seconds) + raise "timed out subscribing to redis at #{App::REDIS_URL}" + end + end + + def clear : Nil + @@lock.synchronize { @@signals.clear } + end + + def signals : Array(Signal) + @@lock.synchronize { @@signals.dup } + end + + # Payloads of the signals published to the channel so far, without waiting + def payloads(channel : String) : Array(JSON::Any) + signals.select(&.channel.==(channel)).map(&.payload) + end + + # Publishes a signal as if from another source + def publish(channel : String, payload) : Nil + redis = Redis.new(url: App::REDIS_URL) + redis.publish(channel, payload.to_json) + ensure + redis.try(&.close) + end + + # Signals published to the channel, waiting for at least `count` to arrive + def received(channel : String, count : Int32 = 1, wait : Time::Span = 2.seconds) : Array(Signal) + received(channel, count, wait) { true } + end + + # Signals published to the channel whose payload matches the block, + # waiting for at least `count` to arrive + def received(channel : String, count : Int32 = 1, wait : Time::Span = 2.seconds, & : JSON::Any -> Bool) : Array(Signal) + deadline = Time.instant + wait + loop do + matching = signals.select { |signal| signal.channel == channel && yield(signal.payload) } + return matching if matching.size >= count || Time.instant >= deadline + sleep 10.milliseconds + end + end +end + +Spec.before_suite { SignalSpy.start } +Spec.before_each { SignalSpy.clear } + +# ControlSystem rows standing in for the systems PlaceOS manages +module SystemsHelper + extend self + + FIXTURES = "./spec/fixtures/placeos" + + # Upserts the systems described by a fixture file, i.e. `systems.json` or `systemJ.json` + # `zones` replaces the fixture's zones, so the systems are found by a zone search + def load_fixture(file : String, zones : Array(String)? = nil) : Array(PlaceOS::Model::ControlSystem) + json = JSON.parse(File.read(File.join(FIXTURES, file))) + (json.as_a? || [json]).map { |details| upsert(details, zones) } + end + + # Creates a system with the provided id, replacing any system with the same id or name + def system( + id : String, + email : String? = nil, + zones : Array(String) = [] of String, + name : String = id, + capacity : Int32 = 0, + features : Array(String) = [] of String, + bookable : Bool = true, + ) : PlaceOS::Model::ControlSystem + PlaceOS::Model::ControlSystem.find?(id).try(&.delete) + PlaceOS::Model::ControlSystem.where(name: name).to_a.each(&.delete) + + system = PlaceOS::Model::Generator.control_system + system.id = id + system.name = name + system.email = email.try { |address| PlaceOS::Model::Email.new(address) } + system.zones = zones + system.capacity = capacity + system.features = Set.new(features) + system.bookable = bookable + system.save! + end + + private def upsert(details : JSON::Any, zones : Array(String)? = nil) : PlaceOS::Model::ControlSystem + system( + id: details["id"].as_s, + email: details["email"]?.try(&.as_s?), + zones: zones || details["zones"]?.try(&.as_a.map(&.as_s)) || [] of String, + name: details["name"]?.try(&.as_s?) || details["id"].as_s, + capacity: details["capacity"]?.try(&.as_i?) || 0, + features: details["features"]?.try(&.as_a.map(&.as_s)) || [] of String, + bookable: details["bookable"]?.try(&.as_bool?) != false, + ) + end +end diff --git a/spec/controllers/placeos_direct_spec.cr b/spec/controllers/placeos_direct_spec.cr new file mode 100644 index 00000000..4d43d754 --- /dev/null +++ b/spec/controllers/placeos_direct_spec.cr @@ -0,0 +1,343 @@ +require "../spec_helper" + +# Covers the code paths that replaced the PlaceOS crystal-client: +# API key auth, redis signals, zone system lookups, guest system access, +# delegated resource tokens and the office365 photo proxy. +module PlaceOSDirectSpec + extend self + + DOMAIN = "toby.staff-api.dev" + O365_TOKEN = "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token" + BOOKINGS = "/api/staff/v1/bookings" + CALENDARS = "/api/staff/v1/calendars" + PEOPLE = "/api/staff/v1/people" + GRAPH_USERS = "https://graph.microsoft.com/v1.0/users" + + def unique(prefix : String) : String + "#{prefix}-#{Random::Secure.hex(6)}" + end + + def authority : PlaceOS::Model::Authority + # ensures the authority exists with the tenant's domain + Mock::Token.generate_auth_user(false, false) + PlaceOS::Model::Authority.find_by_domain(DOMAIN).not_nil! + end + + # Returns the persisted key and the X-API-Key header value + def api_key : {PlaceOS::Model::ApiKey, String} + key = PlaceOS::Model::Generator.api_key(authority) + token = key.x_api_key.not_nil! + key.save! + {key, token} + end + + def api_key_headers(token : String) : HTTP::Headers + HTTP::Headers{"Host" => DOMAIN, "X-API-Key" => token} + end + + def guest_headers(roles : Array(String)) : HTTP::Headers + jwt = UserJWT.new( + iss: "staff-api", + iat: Time.local, + exp: Time.local + 1.week, + domain: DOMAIN, + id: "guest@external.com", + scope: [PlaceOS::Model::UserJWT::Scope::PUBLIC, PlaceOS::Model::UserJWT::Scope::GUEST], + user: UserJWT::Metadata.new( + name: "Guest Person", + email: "guest@external.com", + permissions: UserJWT::Permissions::User, + roles: roles + ) + ).encode + HTTP::Headers{"Host" => DOMAIN, "Authorization" => "Bearer #{jwt}"} + end + + # Stores resource tokens on the user, returning the previous values for restoring + def set_tokens(user : PlaceOS::Model::User, access : String?, refresh : String? = nil, expires : Bool = false) + previous = {user.access_token, user.refresh_token, user.expires_at, user.expires} + user.access_token = access + user.refresh_token = refresh + user.expires_at = nil + user.expires = expires + user.save! + previous + end + + def restore_tokens(user_id : String, previous) + user = PlaceOS::Model::User.find!(user_id) + user.access_token, user.refresh_token, user.expires_at, user.expires = previous + user.save! + end + + def with_delegated_tenant(&) + tenant = get_tenant + tenant.delegated = true + tenant.save! + begin + yield + ensure + tenant = get_tenant + tenant.delegated = false + tenant.save! + end + end + + def calendars_body : String + File.read("./spec/fixtures/calendars/o365/show.json") + end +end + +describe "PlaceOS direct access" do + client = AC::SpecHelper.client + + describe "X-API-Key authentication" do + it "authenticates a valid key and acts as the key's user" do + key, token = PlaceOSDirectSpec.api_key + key_user = key.user.not_nil! + starting = 5.minutes.from_now.to_unix + ending = 40.minutes.from_now.to_unix + + response = client.post(PlaceOSDirectSpec::BOOKINGS, headers: PlaceOSDirectSpec.api_key_headers(token), + body: %({"asset_id":"#{PlaceOSDirectSpec.unique("desk-apikey")}","booking_start":#{starting},"booking_end":#{ending},"booking_type":"desk"})) + response.status_code.should eq 201 + + body = JSON.parse(response.body) + body["user_id"].as_s.should eq key_user.id + body["booked_by_id"].as_s.should eq key_user.id + body["user_email"].as_s.should eq key_user.email.to_s + end + + it "rejects an unknown key with 401" do + _key, token = PlaceOSDirectSpec.api_key + id = token.split('.', 2).first + bad_secret = "#{id}.#{Random::Secure.urlsafe_base64(32)}" + + response = client.get("#{PlaceOSDirectSpec::CALENDARS}/availability", headers: PlaceOSDirectSpec.api_key_headers(bad_secret)) + response.status_code.should eq 401 + + response = client.get("#{PlaceOSDirectSpec::CALENDARS}/availability", headers: PlaceOSDirectSpec.api_key_headers("#{Random.new.hex(16)}.nope")) + response.status_code.should eq 401 + end + + it "rejects an expired key with 401" do + key, token = PlaceOSDirectSpec.api_key + # model validation forbids saving a past expiry, so set it directly + PgORM::Database.exec_sql("UPDATE api_key SET expires_at = $1 WHERE id = $2", 1.hour.ago, key.id.not_nil!) + PlaceOS::Model::ApiKey.find!(key.id.not_nil!).expired?.should be_true + + response = client.get("#{PlaceOSDirectSpec::CALENDARS}/availability", headers: PlaceOSDirectSpec.api_key_headers(token)) + response.status_code.should eq 401 + response.body.should contain "API key has expired" + end + end + + describe "signals" do + it "publishes booking changes globally and scoped to the authority" do + authority_id = PlaceOSDirectSpec.authority.id.not_nil! + asset_id = PlaceOSDirectSpec.unique("desk-signal-scope") + starting = 5.minutes.from_now.to_unix + ending = 40.minutes.from_now.to_unix + + created = JSON.parse(client.post(PlaceOSDirectSpec::BOOKINGS, headers: Mock::Headers.office365_guest, + body: %({"asset_id":"#{asset_id}","booking_start":#{starting},"booking_end":#{ending},"booking_type":"desk"})).body) + id = created["id"] + + global = SignalSpy.received("placeos/staff/booking/changed", &.["id"].==(id)) + scoped = SignalSpy.received("placeos/#{authority_id}/staff/booking/changed", &.["id"].==(id)) + global.size.should eq 1 + scoped.size.should eq 1 + + {global.first.payload, scoped.first.payload}.each do |payload| + payload["action"].should eq "create" + payload["booking_type"].should eq "desk" + payload["booking_start"].should eq starting + payload["booking_end"].should eq ending + payload["resource_id"].should eq asset_id + payload["user_email"].should eq created["user_email"] + end + global.first.payload.should eq scoped.first.payload + end + end + + describe "Utils::PlaceOSHelpers.systems_in_zones" do + it "returns systems in any of the zones" do + zone_a = PlaceOSDirectSpec.unique("zone-a") + zone_b = PlaceOSDirectSpec.unique("zone-b") + other = PlaceOSDirectSpec.unique("zone-other") + sys_a = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-a"), zones: [zone_a]) + sys_b = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-b"), zones: [other, zone_b]) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-c"), zones: [other]) + + found = Utils::PlaceOSHelpers.systems_in_zones([zone_a, zone_b], [] of String, nil, nil) + found.compact_map(&.id).sort!.should eq [sys_a.id, sys_b.id].compact.sort! + end + + it "requires all of the features" do + zone = PlaceOSDirectSpec.unique("zone-feat") + both = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-both"), zones: [zone], features: ["vc", "whiteboard", "tv"]) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-vc"), zones: [zone], features: ["vc"]) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-none"), zones: [zone]) + + found = Utils::PlaceOSHelpers.systems_in_zones([zone], ["vc", "whiteboard"], nil, nil) + found.compact_map(&.id).should eq [both.id] + + Utils::PlaceOSHelpers.systems_in_zones([zone], [] of String, nil, nil).size.should eq 3 + end + + it "filters by minimum capacity" do + zone = PlaceOSDirectSpec.unique("zone-cap") + exact = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-cap8"), zones: [zone], capacity: 8) + big = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-cap20"), zones: [zone], capacity: 20) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-cap4"), zones: [zone], capacity: 4) + + found = Utils::PlaceOSHelpers.systems_in_zones([zone], [] of String, 8, nil) + found.compact_map(&.id).sort!.should eq [exact.id, big.id].compact.sort! + end + + it "filters by bookable" do + zone = PlaceOSDirectSpec.unique("zone-bookable") + bookable = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-bookable"), zones: [zone], bookable: true) + unbookable = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-unbookable"), zones: [zone], bookable: false) + + Utils::PlaceOSHelpers.systems_in_zones([zone], [] of String, nil, true).compact_map(&.id).should eq [bookable.id] + Utils::PlaceOSHelpers.systems_in_zones([zone], [] of String, nil, false).compact_map(&.id).should eq [unbookable.id] + Utils::PlaceOSHelpers.systems_in_zones([zone], [] of String, nil, nil).size.should eq 2 + end + + it "combines the filters" do + zone_a = PlaceOSDirectSpec.unique("zone-combo-a") + zone_b = PlaceOSDirectSpec.unique("zone-combo-b") + match = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-match"), zones: [zone_b], capacity: 10, features: ["vc"], bookable: true) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-small"), zones: [zone_a], capacity: 2, features: ["vc"], bookable: true) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-nofeat"), zones: [zone_a], capacity: 10, bookable: true) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-closed"), zones: [zone_a], capacity: 10, features: ["vc"], bookable: false) + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-elsewhere"), zones: [PlaceOSDirectSpec.unique("zone-x")], capacity: 10, features: ["vc"], bookable: true) + + found = Utils::PlaceOSHelpers.systems_in_zones([zone_a, zone_b], ["vc"], 5, true) + found.compact_map(&.id).should eq [match.id] + end + + it "returns nothing for unrelated zones" do + SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-unrelated"), zones: [PlaceOSDirectSpec.unique("zone-used")]) + Utils::PlaceOSHelpers.systems_in_zones([PlaceOSDirectSpec.unique("zone-empty")], [] of String, nil, nil).should be_empty + end + end + + describe "guest system access" do + it "forbids a guest from a system not in their roles" do + allowed = PlaceOSDirectSpec.unique("sys-guest-allowed") + forbidden = SystemsHelper.system(id: PlaceOSDirectSpec.unique("sys-guest-forbidden"), email: "#{PlaceOSDirectSpec.unique("room")}@example.com") + headers = PlaceOSDirectSpec.guest_headers([PlaceOSDirectSpec.unique("evt"), allowed]) + starting = 5.minutes.from_now.to_unix + ending = 40.minutes.from_now.to_unix + + response = client.get("#{PlaceOSDirectSpec::CALENDARS}/availability?system_ids=#{forbidden.id}&period_start=#{starting}&period_end=#{ending}", headers: headers) + response.status_code.should eq 403 + end + + it "lets a guest through to a system in their roles" do + # the system doesn't exist, so passing the guard results in a 404 from the lookup + allowed = PlaceOSDirectSpec.unique("sys-guest-missing") + headers = PlaceOSDirectSpec.guest_headers([PlaceOSDirectSpec.unique("evt"), allowed]) + starting = 5.minutes.from_now.to_unix + ending = 40.minutes.from_now.to_unix + + response = client.get("#{PlaceOSDirectSpec::CALENDARS}/availability?system_ids=#{allowed}&period_start=#{starting}&period_end=#{ending}", headers: headers) + response.status_code.should eq 404 + end + end + + describe "delegated tenants" do + it "uses the user's stored resource token" do + email = "delegated-token@example.com" + headers = Mock::Headers.office365_normal_user(email) + user = Mock::Token.generate_normal_auth_user(email) + stored = "stored-#{Random::Secure.hex(8)}" + previous = PlaceOSDirectSpec.set_tokens(user, stored) + + authorization = nil + WebMock.stub(:get, "#{PlaceOSDirectSpec::GRAPH_USERS}/delegated-token%40example.com/calendars") + .to_return do |request| + authorization = request.headers["Authorization"]? + HTTP::Client::Response.new(200, PlaceOSDirectSpec.calendars_body, HTTP::Headers{"Content-Type" => "application/json"}) + end + + begin + PlaceOSDirectSpec.with_delegated_tenant do + response = client.get(PlaceOSDirectSpec::CALENDARS, headers: headers) + response.status_code.should eq 200 + authorization.should eq "Bearer #{stored}" + JSON.parse(response.body).as_a.should_not be_empty + end + ensure + PlaceOSDirectSpec.restore_tokens(user.id.not_nil!, previous) + end + end + + it "responds 511 when the user has no resource token" do + email = "delegated-none@example.com" + headers = Mock::Headers.office365_normal_user(email) + user = Mock::Token.generate_normal_auth_user(email) + previous = PlaceOSDirectSpec.set_tokens(user, nil, nil) + + begin + PlaceOSDirectSpec.with_delegated_tenant do + response = client.get(PlaceOSDirectSpec::CALENDARS, headers: headers) + response.status_code.should eq 511 + end + ensure + PlaceOSDirectSpec.restore_tokens(user.id.not_nil!, previous) + end + end + end + + describe "people photo" do + it "streams the office365 photo using the user's resource token" do + email = "photo-viewer@example.com" + headers = Mock::Headers.office365_normal_user(email) + user = Mock::Token.generate_normal_auth_user(email) + stored = "photo-#{Random::Secure.hex(8)}" + previous = PlaceOSDirectSpec.set_tokens(user, stored) + photo = "fake-jpeg-bytes-#{Random::Secure.hex(4)}" + target = "someone@example.com" + + WebMock.stub(:post, PlaceOSDirectSpec::O365_TOKEN) + .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) + + authorization = nil + WebMock.stub(:get, "#{PlaceOSDirectSpec::GRAPH_USERS}/#{URI.encode_path_segment(target)}/photo/$value") + .to_return do |request| + authorization = request.headers["Authorization"]? + HTTP::Client::Response.new(200, photo, HTTP::Headers{"Content-Type" => "image/jpeg"}) + end + + begin + response = client.get("#{PlaceOSDirectSpec::PEOPLE}/#{target}/photo", headers: headers) + response.status_code.should eq 200 + response.headers["Content-Type"]?.should eq "image/jpeg" + response.body.should eq photo + authorization.should eq "Bearer #{stored}" + ensure + PlaceOSDirectSpec.restore_tokens(user.id.not_nil!, previous) + end + end + + it "returns 511 when the user has no resource token" do + email = "photo-viewer-no-token@example.com" + headers = Mock::Headers.office365_normal_user(email) + user = Mock::Token.generate_normal_auth_user(email) + previous = PlaceOSDirectSpec.set_tokens(user, nil) + + WebMock.stub(:post, PlaceOSDirectSpec::O365_TOKEN) + .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) + + begin + response = client.get("#{PlaceOSDirectSpec::PEOPLE}/someone@example.com/photo", headers: headers) + response.status_code.should eq 511 + ensure + PlaceOSDirectSpec.restore_tokens(user.id.not_nil!, previous) + end + end + end +end diff --git a/spec/controllers/recurring_booking_approval_spec.cr b/spec/controllers/recurring_booking_approval_spec.cr index 2eaf744b..5251243c 100644 --- a/spec/controllers/recurring_booking_approval_spec.cr +++ b/spec/controllers/recurring_booking_approval_spec.cr @@ -11,19 +11,6 @@ describe Bookings do client = AC::SpecHelper.client headers = Mock::Headers.office365_guest - # the approval flow saves the booking and spawns a signal to the placeos - # engine -- stub the outbound calls so the spawned fibers don't hit the network - stub_engine = -> do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - end - # create a daily recurring booking through the REST API and return its id create_recurring = ->(asset_id : String) do status, body = BookingsHelper.http_create_booking( @@ -47,8 +34,6 @@ describe Bookings do describe "recurring booking approval" do it "creates a recurring booking and approves one of its instances" do - stub_engine.call - booking_id = create_recurring.call("desk-approve-1") instance = instance_time.call(booking_id) @@ -70,8 +55,6 @@ describe Bookings do end it "re-approves an instance that was previously rejected" do - stub_engine.call - booking_id = create_recurring.call("desk-approve-2") instance = instance_time.call(booking_id) @@ -93,8 +76,6 @@ describe Bookings do end it "approves only the targeted instance, leaving siblings unapproved" do - stub_engine.call - booking_id = create_recurring.call("desk-approve-3") booking = Booking.find(booking_id) instances = booking.calculate_daily(2.days.from_now, 6.days.from_now).instances @@ -114,8 +95,6 @@ describe Bookings do describe "updating a recurring booking instance" do it "adjusts the end time to be a little earlier without a self clash" do - stub_engine.call - booking_id = create_recurring.call("desk-update-1") instance = instance_time.call(booking_id) diff --git a/spec/controllers/recurring_booking_ext_data_spec.cr b/spec/controllers/recurring_booking_ext_data_spec.cr index 98f8ea59..889de68a 100644 --- a/spec/controllers/recurring_booking_ext_data_spec.cr +++ b/spec/controllers/recurring_booking_ext_data_spec.cr @@ -44,19 +44,6 @@ describe Bookings do client = AC::SpecHelper.client headers = Mock::Headers.office365_guest - # updating an instance saves the booking and spawns a signal to the placeos - # engine -- stub the outbound calls so the spawned fibers don't hit the network - stub_engine = -> do - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/host_changed") - .to_return(body: "") - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") - .to_return(body: "") - end - # create a daily recurring parking booking, carrying extension data, through # the REST API. Returns {id, booking_start, booking_end} create_recurring = ->(asset_id : String) do @@ -116,8 +103,6 @@ describe Bookings do describe "recurring booking extension data" do it "returns the parent's extension data on every expanded occurrence" do - stub_engine.call - booking_id, _start, _end = create_recurring.call("unallocated-Ez15vtMs") listed = list_bookings.call @@ -131,8 +116,6 @@ describe Bookings do end it "keeps the parent's extension data on an occurrence after its asset_id is changed" do - stub_engine.call - booking_id, _start, _end = create_recurring.call("unallocated-Ez15vtMs") instance = instances_of.call(booking_id).first @@ -166,8 +149,6 @@ describe Bookings do end it "keeps the extension data on sibling occurrences after one asset_id is changed" do - stub_engine.call - booking_id, _start, _end = create_recurring.call("unallocated-Ez15vtMs") instance = instances_of.call(booking_id).first @@ -201,8 +182,6 @@ describe Bookings do "an empty object" => JSON::Any.new({} of String => JSON::Any), }.each do |description, extension_data| it "keeps inheriting when an occurrence update sends #{description}" do - stub_engine.call - booking_id, _start, _end = create_recurring.call("unallocated-Ez15vtMs") instance = instances_of.call(booking_id).first @@ -234,8 +213,6 @@ describe Bookings do end it "snapshots the effective extension data when an occurrence is updated" do - stub_engine.call - booking_id, _start, _end = create_recurring.call("unallocated-Ez15vtMs") instance = instances_of.call(booking_id).first @@ -350,8 +327,6 @@ describe Bookings do end it "returns the extension data on every occurrence of the series" do - stub_engine.call - booking_id, first_day = create_parking.call listed = list_parking.call(first_day) listed.size.should be > 1 @@ -363,8 +338,6 @@ describe Bookings do end it "keeps the extension data when only asset_id is sent for one occurrence" do - stub_engine.call - booking_id, first_day = create_parking.call instances = parking_instances.call(booking_id, first_day) instances.size.should be > 1 @@ -392,8 +365,6 @@ describe Bookings do end it "keeps the extension data when asset_id is sent with an empty asset_ids" do - stub_engine.call - booking_id, first_day = create_parking.call instance = parking_instances.call(booking_id, first_day).first @@ -417,8 +388,6 @@ describe Bookings do end it "still inherits later parent extension data changes on an occurrence whose asset changed" do - stub_engine.call - booking_id, first_day = create_parking.call instance = parking_instances.call(booking_id, first_day).first @@ -448,8 +417,6 @@ describe Bookings do end it "picks up the series' extension data on an occurrence re-allocated before any was set" do - stub_engine.call - # a series created without extension data first_day = Time.local(perth).at_beginning_of_day + 1.day response = client.post(BOOKINGS_BASE, headers: headers, body: { @@ -494,8 +461,6 @@ describe Bookings do end it "snapshots all effective data when an occurrence's extension data changes" do - stub_engine.call - booking_id, first_day = create_parking.call instance = parking_instances.call(booking_id, first_day).first @@ -532,8 +497,6 @@ describe Bookings do end it "keeps inheriting when the instance extension-data route receives an empty object" do - stub_engine.call - booking_id, first_day = create_parking.call instance = parking_instances.call(booking_id, first_day).first diff --git a/spec/controllers/surveys/triggers_spec.cr b/spec/controllers/surveys/triggers_spec.cr index 3dcd9f2a..c002da7b 100644 --- a/spec/controllers/surveys/triggers_spec.cr +++ b/spec/controllers/surveys/triggers_spec.cr @@ -11,10 +11,6 @@ describe "Survey Triggers", tags: ["survey"] do Booking.truncate Survey.truncate Survey::Invitation.truncate - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") - .to_return(body: File.read("./spec/fixtures/tokens/placeos_token.json")) - WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/booking/changed") - .to_return(body: "") Timecop.scale(600) # 1 second == 10 minutes end diff --git a/spec/fixtures/tokens/placeos_token.json b/spec/fixtures/tokens/placeos_token.json deleted file mode 100644 index aaa935f4..00000000 --- a/spec/fixtures/tokens/placeos_token.json +++ /dev/null @@ -1 +0,0 @@ -{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"eyJ0eXAiOiJKV1QiLCJub25jZSI6IlJoZllGTldoa3cyZlRpaXo5WEExbW1HbWNDV2RhaTcyODIySXc2Qkl3dDgiLCJhbGciOiJSUzI1NiIsIng1dCI6ImppYk5ia0ZTU2JteFBZck45Q0ZxUms0SzRndyIsImtpZCI6ImppYk5ia0ZTU2JteFBZck45Q0ZxUms0SzRndyJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLm1pY3Jvc29mdC5jb20iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9iYjg5Njc0YS0yMzhiLTRiN2QtOTFlYy02YmViYWQ4MzU1M2EvIiwiaWF0IjoxNTk5NDM3OTM3LCJuYmYiOjE1OTk0Mzc5MzcsImV4cCI6MTU5OTQ0MTgzNywiYWlvIjoiRTJCZ1lLajBFdnAxTDNJWlYzWFJtKzIyTTNiMkFBQT0iLCJhcHBfZGlzcGxheW5hbWUiOiJleHRlcm5hbC1kZXYtZ3JhcGgtbGliIiwiYXBwaWQiOiI2MzE2YmM4Ni1iNjE1LTQ5ZTAtYWQyNC05ODViMzk4OThjYjciLCJhcHBpZGFjciI6IjEiLCJpZHAiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9iYjg5Njc0YS0yMzhiLTRiN2QtOTFlYy02YmViYWQ4MzU1M2EvIiwiaWR0eXAiOiJhcHAiLCJvaWQiOiJmM2RhMGM3Mi1hYTMzLTQ2NGQtODRjYy0wYWNhOGMxZjFiNTciLCJyaCI6IjAuQUFBQVNtZUp1NHNqZlV1UjdHdnJyWU5WT29hOEZtTVZ0dUJKclNTWVd6bUpqTGRuQUFBLiIsInJvbGVzIjpbIkdyb3VwLlJlYWQuQWxsIiwiQ29udGFjdHMuUmVhZFdyaXRlIiwiVXNlci5SZWFkLkFsbCIsIkNhbGVuZGFycy5SZWFkV3JpdGUiXSwic3ViIjoiZjNkYTBjNzItYWEzMy00NjRkLTg0Y2MtMGFjYThjMWYxYjU3IiwidGVuYW50X3JlZ2lvbl9zY29wZSI6Ik9DIiwidGlkIjoiYmI4OTY3NGEtMjM4Yi00YjdkLTkxZWMtNmJlYmFkODM1NTNhIiwidXRpIjoiYnRlTFVvRWU5a0tRYTZJWmNPNGxBQSIsInZlciI6IjEuMCIsInhtc190Y2R0IjoxNDI5NzUzNDY5fQ.Tp0Roy8NnaT9Vz-UKhP_u35jQYHICXIO_JHWaG6RyLHXuK74kxyz6Zkve501xg2INe71g2bu7c2HJD_1JLy7xrVp3K1eTZHfOJD9w-bCuhRNUMHikJra8HAW-kNQ6XiWTO2bTnJx1oJxj-KIq6nYRIqWbU1Qrnwgv9yuRSeB7IpOlrhHlSr-wW-k052inrVjKD_zm2MxWv90AXLNyYpEIskZkj6hLTws57dCvwNDibhVZtuBKPpE4cJFwMKDLMDLUjZE6DQUnAf8iMxNcbIbEQ1NrkBqUZsKFG-1PtjQSeL8XIljjz99EdOLMdRypvSATIy8r7gsGJM3Hgn60DBorg"} \ No newline at end of file diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index c9ecdc89..b19b9482 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -12,6 +12,7 @@ require "uuid" # If you have a testing environment, replace this with a test config file require "../src/config" require "webmock" +require "./controllers/helpers/placeos_helper" PgORM::Database.parse(ENV["PG_DATABASE_URL"]) diff --git a/src/constants.cr b/src/constants.cr index 34bf5a22..255b3144 100644 --- a/src/constants.cr +++ b/src/constants.cr @@ -23,11 +23,8 @@ module App Log = ::Log.for(NAME) LOG_BACKEND = ActionController.default_backend - PLACE_URI = ENV["PLACE_URI"]?.presence || abort("PLACE_URI not in environment") - - PLACE_HOST_HEADER = ENV["PLACE_HOST_HEADER"]?.presence - - SSL_VERIFY_NONE = !!ENV["SSL_VERIFY_NONE"]?.presence.try { |var| var.downcase.in?("1", "true") } + # Signals are published to redis + REDIS_URL = ENV["REDIS_URL"]?.presence || "redis://localhost:6379" PG_UNIQUE_CONSTRAINT_REGEX = /duplicate key value violates unique constraint/ diff --git a/src/controllers/application.cr b/src/controllers/application.cr index 431c3fe7..7d340ee0 100644 --- a/src/controllers/application.cr +++ b/src/controllers/application.cr @@ -174,7 +174,8 @@ abstract class Application < ActionController::Base # 511 if the user needs an access token via SSO @[AC::Route::Exception(Error::NeedsAuthentication, status_code: HTTP::Status::NETWORK_AUTHENTICATION_REQUIRED)] - def action_not_implemented(error) : CommonError + @[AC::Route::Exception(PlaceOS::Model::Error::NoResourceToken, status_code: HTTP::Status::NETWORK_AUTHENTICATION_REQUIRED)] + def needs_authentication(error) : CommonError Log.debug { error.message } render_error(error) end @@ -250,7 +251,6 @@ abstract class Application < ActionController::Base # handler for a few different errors @[AC::Route::Exception(Error::NotAllowed, status_code: HTTP::Status::METHOD_NOT_ALLOWED)] @[AC::Route::Exception(PgORM::Error, status_code: HTTP::Status::INTERNAL_SERVER_ERROR)] - @[AC::Route::Exception(::PlaceOS::Client::API::Error, status_code: HTTP::Status::NOT_FOUND)] @[AC::Route::Exception(JSON::SerializableError, status_code: HTTP::Status::BAD_REQUEST)] @[AC::Route::Exception(::Enumerable::EmptyError, status_code: HTTP::Status::NOT_FOUND)] # TODO: Should be caught where it's happening, or the code refactored. def handled_generic_error(error) : CommonError diff --git a/src/controllers/bookings.cr b/src/controllers/bookings.cr index 872c770d..178d9c55 100644 --- a/src/controllers/bookings.cr +++ b/src/controllers/bookings.cr @@ -539,7 +539,7 @@ class Bookings < Application ) spawn do - get_placeos_client.root.signal("staff/guest/attending", { + signal("staff/guest/attending", { action: :booking_created, id: guest.id, booking_id: booking.id, @@ -559,7 +559,7 @@ class Bookings < Application spawn do begin - get_placeos_client.root.signal("staff/booking/changed", { + signal("staff/booking/changed", { action: :create, id: booking.id, booking_type: booking.booking_type, @@ -765,7 +765,7 @@ class Bookings < Application if !previously_visiting spawn do - get_placeos_client.root.signal("staff/guest/attending", { + signal("staff/guest/attending", { action: :booking_updated, id: guest.id, booking_id: existing_booking.id, @@ -797,7 +797,7 @@ class Bookings < Application if original_host_email.to_s.downcase != existing_booking.user_email.to_s.downcase spawn do begin - get_placeos_client.root.signal("staff/booking/host_changed", { + signal("staff/booking/host_changed", { action: :host_changed, booking_id: existing_booking.id, resource_id: existing_booking.asset_id, @@ -868,7 +868,7 @@ class Bookings < Application spawn do begin - get_placeos_client.root.signal("staff/booking/changed", { + signal("staff/booking/changed", { action: :cancelled, id: booking_local.id, instance: booking_local.instance, @@ -1013,7 +1013,7 @@ class Bookings < Application end spawn do - get_placeos_client.root.signal("staff/guest/#{action}", { + signal("staff/guest/#{action}", { action: action, id: guest.id, induction: induction, @@ -1156,7 +1156,7 @@ class Bookings < Application if !previously_visiting spawn do - get_placeos_client.root.signal("staff/guest/attending", { + signal("staff/guest/attending", { action: :booking_updated, id: guest.id, booking_id: booking.id, @@ -1264,7 +1264,7 @@ class Bookings < Application private def signal_guest_checkin(guest : Guest, checkin : Bool) : Nil spawn do - get_placeos_client.root.signal("staff/guest/checkin", { + signal("staff/guest/checkin", { action: :checkin, id: guest.id, checkin: checkin, @@ -1293,7 +1293,7 @@ class Bookings < Application spawn do begin - get_placeos_client.root.signal("staff/booking/changed", { + signal("staff/booking/changed", { action: signal, id: booking.id, instance: booking.instance, diff --git a/src/controllers/calendars.cr b/src/controllers/calendars.cr index 6692fca2..b9546cc4 100644 --- a/src/controllers/calendars.cr +++ b/src/controllers/calendars.cr @@ -34,9 +34,9 @@ class Calendars < Application ) end - getter! matching_calendars : Hash(String, PlaceOS::Client::API::Models::System?) + getter! matching_calendars : Hash(String, PlaceOS::Model::ControlSystem?) - record Availability, id : String, system : PlaceOS::Client::API::Models::System? = nil, availability : Array(PlaceCalendar::Availability)? = nil do + record Availability, id : String, system : PlaceOS::Model::ControlSystem? = nil, availability : Array(PlaceCalendar::Availability)? = nil do include JSON::Serializable end diff --git a/src/controllers/events.cr b/src/controllers/events.cr index 49364300..87f7751d 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -38,7 +38,7 @@ class Events < Application if system_id system = PlaceOS::Model::ControlSystem.find!(system_id) - return if check_access(current_user.groups, system.zones || [] of String).can_manage? + return if check_access(current_user.groups, system.zones).can_manage? end raise Error::Forbidden.new("user not in an appropriate user group or involved in the meeting") @@ -47,7 +47,7 @@ class Events < Application private def confirm_access_for_add_attendee( event : PlaceCalendar::Event, metadata : EventMetadata, - system : PlaceOS::Client::API::Models::System? = nil, + system : PlaceOS::Model::ControlSystem? = nil, ) return if metadata.permission.public? return if is_support? @@ -55,7 +55,7 @@ class Events < Application if user = current_user return if event && (event_creator = event.creator) && (event_creator.downcase == user.email.downcase) if system - return if check_access(current_user.groups, system.zones || [] of String).can_manage? + return if check_access(current_user.groups, system.zones).can_manage? end return if metadata.permission.open? && (authority = user.authority) && (event_tenant = metadata.tenant) && (authority.domain == event_tenant.domain) end @@ -67,7 +67,7 @@ class Events < Application email : String, cal_id : String, event_id : String, - system : PlaceOS::Client::API::Models::System? = nil, + system : PlaceOS::Model::ControlSystem? = nil, ) : PlaceCalendar::Event? return if is_support? @@ -76,7 +76,7 @@ class Events < Application event = get_hosts_event_or_event(cal_id, event_id) return event if event && (event_creator = event.creator) && (event_creator.downcase == user.email.downcase) if system - return event if check_access(current_user.groups, system.zones || [] of String).can_manage? + return event if check_access(current_user.groups, system.zones).can_manage? end end @@ -163,7 +163,7 @@ class Events < Application calendar_rate_limit = [] of String calendar_other_error = [] of String - results = [] of Tuple(String, PlaceOS::Client::API::Models::System?, PlaceCalendar::Event) + results = [] of Tuple(String, PlaceOS::Model::ControlSystem?, PlaceCalendar::Event) mappings.each do |(request, calendar_id, system)| begin results.concat client.list_events(user.email, responses[request]).map { |event| {calendar_id, system, event} } @@ -282,10 +282,10 @@ class Events < Application end # grab the system details for resource calendars, if they exist - system_emails = {} of String => PlaceOS::Client::API::Models::System + system_emails = {} of String => PlaceOS::Model::ControlSystem if !event_resources.empty? - systems = get_placeos_client.systems.with_emails event_resources.values.uniq! - systems.each { |sys| system_emails[sys.email.as(String).downcase] = sys } + systems = systems_with_emails(event_resources.values) + systems.each { |sys| system_emails[sys.email.to_s.downcase] = sys } end response_code = if errors > 0 @@ -412,8 +412,6 @@ class Events < Application # creates a new calendar event @[AC::Route::POST("/", body: :input_event, status_code: HTTP::Status::CREATED)] def create(input_event : PlaceCalendar::Event) : PlaceCalendar::Event - placeos_client = get_placeos_client - # get_user_calendars returns only calendars where the user has write access user_email = user.email.downcase host = input_event.host.try(&.downcase) || user_email @@ -422,8 +420,8 @@ class Events < Application system_id = input_event.system_id || input_event.system.try(&.id) if system_id - system = placeos_client.systems.fetch(system_id) - raise Error::BadUpstreamResponse.new("email.presence must be present on system #{system_id}") unless system_email_presence = system.email.presence + system = find_system!(system_id) + raise Error::BadUpstreamResponse.new("email.presence must be present on system #{system_id}") unless system_email_presence = system.email.to_s.presence system_email = system_email_presence system_attendee = PlaceCalendar::Event::Attendee.new(name: system.display_name.presence || system.name, email: system_email, resource: true) input_event.attendees << system_attendee @@ -521,13 +519,13 @@ class Events < Application ) spawn do - placeos_client.root.signal("staff/guest/attending", { + signal("staff/guest/attending", { action: :meeting_created, system_id: sys.id, event_id: created_event.id, event_ical_uid: created_event.ical_uid, host: host, - resource: sys.email, + resource: sys.email.to_s, event_title: created_event.title, event_summary: created_event.title, event_starting: created_event_start.to_unix, @@ -539,7 +537,7 @@ class Events < Application end end - return StaffApi::Event.augment(created_event, sys.email, sys, meta) + return StaffApi::Event.augment(created_event, sys.email.to_s, sys, meta) end Log.info { "no system provided for event #{created_event.id}" } @@ -586,8 +584,6 @@ class Events < Application changes.id = event_id = original_id system_id = (associated_system || changes.system_id).presence - placeos_client = get_placeos_client - user_cal = user_cal.try &.downcase if user_cal == user.email cal_id = user_cal @@ -598,10 +594,10 @@ class Events < Application end if system_id - system = placeos_client.systems.fetch(system_id) - sys_cal = system.email.presence + system = find_system!(system_id) + sys_cal = system.email.to_s.presence if cal_id.nil? - cal_id = system.email.presence + cal_id = system.email.to_s.presence raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless sys_cal end end @@ -628,7 +624,7 @@ class Events < Application existing_attendees = event.attendees.try(&.map { |a| a.email.downcase }) || [] of String if !tenant.delegated && user_email != host && !user_email.in?(existing_attendees) # may be able to edit on behalf of the user - raise Error::Forbidden.new("user #{user_email} not involved in meeting and no role is permitted to make this change") if !(system && !check_access(user.roles, [system.id] + system.zones).forbidden?) + raise Error::Forbidden.new("user #{user_email} not involved in meeting and no role is permitted to make this change") if !(system && !check_access(user.roles, [system.id.as(String)] + system.zones).forbidden?) end # Check if attendees need updating @@ -694,8 +690,8 @@ class Events < Application if changing_room raise Error::BadRequest.new("system_id must be present when changing room") unless new_system_id = changes.system_id - new_system = placeos_client.systems.fetch(new_system_id) - new_sys_cal = new_system.email.presence.try &.downcase + new_system = find_system!(new_system_id) + new_sys_cal = new_system.email.to_s.presence.try &.downcase raise AC::Route::Param::ValueError.new("attempting to move location and system '#{new_system.name}' (#{new_system_id}) does not have a resource email address specified", "event.system_id") unless new_sys_cal previous_meta_for_signal = system_id ? get_event_metadata(event, system_id) : nil @@ -889,13 +885,13 @@ class Events < Application sys = system raise Error::BadUpstreamResponse.new("event_start must be present on updated event #{updated_event.id}") unless updated_event_start = updated_event.event_start - placeos_client.root.signal("staff/guest/attending", { + signal("staff/guest/attending", { action: :meeting_update, system_id: sys.id, event_id: event_id, event_ical_uid: updated_event.ical_uid, host: signal_host, - resource: sys.email, + resource: sys.email.to_s, event_title: updated_event.title, event_summary: updated_event.title, event_starting: updated_event_start.to_unix, @@ -917,7 +913,7 @@ class Events < Application end if !resource_calendars.empty? - systems = placeos_client.systems.with_emails(resource_calendars) + systems = systems_with_emails(resource_calendars) if sys = systems.first? raise Error::BadUpstreamResponse.new("id must be present on system") unless sys_id = sys.id @@ -943,16 +939,15 @@ class Events < Application @[AC::Param::Info(description: "(office365 only) when true, existing attendees are also emailed about the change, otherwise only the new attendee is notified", example: "false")] notify_existing_attendees : Bool = false, ) : Attendee | PlaceCalendar::Event::Attendee - placeos_client = get_placeos_client event_id = original_id email = attendee.email.strip.downcase cal_id = user_cal.try &.downcase if system_id - system = placeos_client.systems.fetch(system_id) - sys_cal = system.email.presence + system = find_system!(system_id) + sys_cal = system.email.to_s.presence if cal_id.nil? - cal_id = system.email.presence + cal_id = system.email.to_s.presence raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless sys_cal end end @@ -1056,13 +1051,13 @@ class Events < Application sys = system raise Error::BadUpstreamResponse.new("event_start must be present on updated event #{updated_event.id}") unless updated_event_start = updated_event.event_start - placeos_client.root.signal("staff/guest/attending", { + signal("staff/guest/attending", { action: :meeting_update, system_id: sys.id, event_id: event_id, event_ical_uid: updated_event.ical_uid, host: host, - resource: sys.email, + resource: sys.email.to_s, event_title: updated_event.title, event_summary: updated_event.title, event_starting: updated_event_start.to_unix, @@ -1088,16 +1083,15 @@ class Events < Application @[AC::Param::Info(name: "calendar", description: "the calendar associated with this event id", example: "user@org.com")] user_cal : String? = nil, ) : Nil - placeos_client = get_placeos_client event_id = original_id email = attendee_email.strip.downcase cal_id = user_cal.try &.downcase if system_id - system = placeos_client.systems.fetch(system_id) - sys_cal = system.email.presence + system = find_system!(system_id) + sys_cal = system.email.to_s.presence if cal_id.nil? - cal_id = system.email.presence + cal_id = system.email.to_s.presence raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless sys_cal end end @@ -1184,7 +1178,6 @@ class Events < Application ical_uid : String? = nil, ) : JSON::Any event_id = original_id - placeos_client = get_placeos_client # Guest access if user_token.guest_scope? @@ -1193,8 +1186,8 @@ class Events < Application raise Error::Forbidden.new("guest #{user_token.id} attempting to view a system they are not associated with") unless system_id == guest_system_id end - system = placeos_client.systems.fetch(system_id) - cal_id = system.email.presence.try &.downcase + system = find_system!(system_id) + cal_id = system.email.to_s.presence.try &.downcase raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless cal_id # attempt to find the metadata @@ -1271,7 +1264,6 @@ class Events < Application protected def update_metadata(changes : Hash(String, JSON::Any), original_id : String, system_id : String, event_calendar : String?, uuid : String?, merge : Bool = false, setup_time : Int64? = nil, breakdown_time : Int64? = nil, setup_event_id : String? = nil, breakdown_event_id : String? = nil) : JSON::Any event_id = original_id - placeos_client = get_placeos_client # Guest access if user_token.guest_scope? @@ -1280,8 +1272,8 @@ class Events < Application raise Error::Forbidden.new("guest #{user_token.id} attempting to view a system they are not associated with") unless system_id == guest_system_id end - system = placeos_client.systems.fetch(system_id) - cal_id = system.email.presence.try &.downcase + system = find_system!(system_id) + cal_id = system.email.to_s.presence.try &.downcase raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless cal_id user_email = user_token.guest_scope? ? cal_id : user.email.downcase @@ -1335,7 +1327,7 @@ class Events < Application raise Error::BadUpstreamResponse.new("ical_uid must be present on event #{upstream_event_id}") unless event_ical_uid = event.ical_uid raise Error::BadUpstreamResponse.new("event_start must be present on event") unless event_start = event.event_start raise Error::BadUpstreamResponse.new("event_end must be present on event") unless event_end = event.event_end - raise Error::BadUpstreamResponse.new("email must be present on system") unless system_email = system.email + raise Error::BadUpstreamResponse.new("email must be present on system") unless system_email = system.email.to_s.presence raise Error::BadUpstreamResponse.new("host must be present on event") unless event_host = event.host is_host = event_host.downcase == cal_id.downcase @@ -1371,14 +1363,14 @@ class Events < Application meta.save! spawn do - placeos_client.root.signal("staff/event/changed", { + signal("staff/event/changed", { action: :update, system_id: system.id, event_id: original_id, event_ical_uid: meta.ical_uid, host: effective_host(meta), organiser_email: meta.host_email, - resource: system.email, + resource: system.email.to_s, event: event, ext_data: meta.ext_data, }) @@ -1468,7 +1460,6 @@ class Events < Application @[AC::Param::Info(name: "calendar", description: "the users calendar associated with this event", example: "user@org.com")] user_cal : String? = nil, ) : PlaceCalendar::Event - placeos_client = get_placeos_client event_id = original_id # Guest access @@ -1480,8 +1471,8 @@ class Events < Application if system_id # Need to grab the calendar associated with this system - system = placeos_client.systems.fetch(system_id) - cal_id = system.email + system = find_system!(system_id) + cal_id = system.email.to_s.presence user_email = user_token.guest_scope? ? cal_id : user.email raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless cal_id @@ -1533,7 +1524,7 @@ class Events < Application end if !resource_calendars.empty? - systems = placeos_client.systems.with_emails(resource_calendars) + systems = systems_with_emails(resource_calendars) if system = systems.first? return StaffApi::Event.augment(event, user_cal, system, metadata) end @@ -1581,8 +1572,6 @@ class Events < Application end protected def cancel_event(event_id : String, notify_guests : Bool, system_id : String?, user_cal : String?, delete : Bool) - placeos_client = get_placeos_client - user_cal = user_cal.try &.strip.downcase if user_cal == user.email cal_id = user_cal @@ -1593,8 +1582,8 @@ class Events < Application end if system_id - system = placeos_client.systems.fetch(system_id) - sys_cal = system.email.presence.try(&.strip.downcase) + system = find_system!(system_id) + sys_cal = system.email.to_s.presence.try(&.strip.downcase) if cal_id.nil? cal_id = sys_cal raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless sys_cal @@ -1621,7 +1610,7 @@ class Events < Application existing_attendees = event.attendees.try(&.map { |a| a.email.downcase }) || [] of String unless user_email == host || user_email.in?(existing_attendees) # may be able to delete on behalf of the user - raise Error::Forbidden.new("user #{user_email} not involved in meeting and no role is permitted to make this change") if !(system && !check_access(user.roles, [system.id] + system.zones).forbidden?) + raise Error::Forbidden.new("user #{user_email} not involved in meeting and no role is permitted to make this change") if !(system && !check_access(user.roles, [system.id.as(String)] + system.zones).forbidden?) end end @@ -1674,8 +1663,8 @@ class Events < Application system_id : String, ) : Bool # Check this system has an associated resource - system = get_placeos_client.systems.fetch(system_id) - cal_id = system.email + system = find_system!(system_id) + cal_id = system.email.to_s.presence raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless cal_id client.accept_event(cal_id, id: event_id, calendar_id: cal_id) end @@ -1758,8 +1747,8 @@ class Events < Application system_id : String, ) : Bool # Check this system has an associated resource - system = get_placeos_client.systems.fetch(system_id) - cal_id = system.email + system = find_system!(system_id) + cal_id = system.email.to_s.presence raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless cal_id event = client.get_event(cal_id, id: event_id, calendar_id: cal_id) @@ -1786,7 +1775,7 @@ class Events < Application metadata = EventMetadata.by_tenant(tenant.id).where(ical_uid: ical_uid, system_id: system_id).first? parent_meta = false else - cal_id = get_placeos_client.systems.fetch(system_id).email + cal_id = find_system!(system_id).email.to_s.presence return [] of Guest unless cal_id event = client.get_event(user.email, id: event_id, calendar_id: cal_id) @@ -1946,15 +1935,15 @@ class Events < Application raise AC::Route::Param::ValueError.new("user doesn't have write access to #{cal_id}", "calendar") unless found end - system = get_placeos_client.systems.fetch(system_id) - sys_cal = system.email.presence.try(&.strip.downcase) + system = find_system!(system_id) + sys_cal = system.email.to_s.presence.try(&.strip.downcase) if cal_id.nil? cal_id = sys_cal raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless sys_cal end # defaults to the room email - cal_id = cal_id.presence || system.email.as(String) + cal_id = cal_id.presence || system.email.to_s user_email = user_token.guest_scope? ? cal_id : user.email.downcase event = client.get_event(user_email, id: event_id, calendar_id: cal_id) raise Error::NotFound.new("failed to find event #{event_id} searching on #{cal_id} as #{user_email}") unless event @@ -2023,7 +2012,7 @@ class Events < Application # Update PlaceOS with an signal "staff/guest/checkin" spawn do - get_placeos_client.root.signal("staff/guest/checkin", { + signal("staff/guest/checkin", { action: :checkin, id: guest.id, checkin: checkin, @@ -2193,7 +2182,7 @@ class Events < Application meta.host_email = event.host.as(String).downcase meta.event_start = starting meta.event_end = ending - meta.resource_calendar = system.email.to_s.as(String).downcase + meta.resource_calendar = system.email.to_s.downcase meta.tenant_id = tenant.id meta.cancelled = cancelled meta.save! @@ -2213,7 +2202,7 @@ class Events < Application return if skip_signal spawn do - get_placeos_client.root.signal("staff/event/changed", { + signal("staff/event/changed", { action: action, system_id: system.id, event_id: meta.event_id, @@ -2261,13 +2250,13 @@ class Events < Application end end - get_placeos_client.root.signal("staff/event/changed", { + signal("staff/event/changed", { action: :cancelled, reason: reason, system_id: system.id, event_id: event_id, event_ical_uid: event_ical_uid, - resource: system.email, + resource: system.email.to_s, event: event, ext_data: meta.try &.ext_data, }) diff --git a/src/controllers/guests.cr b/src/controllers/guests.cr index a73a3594..3571d93f 100644 --- a/src/controllers/guests.cr +++ b/src/controllers/guests.cr @@ -1,3 +1,5 @@ +require "promise" + class Guests < Application base "/api/staff/v1/guests" @@ -99,7 +101,7 @@ class Guests < Application # Process the response (map requests back to responses) errors = 0 - results = [] of Tuple(String, PlaceOS::Client::API::Models::System?, PlaceCalendar::Event) + results = [] of Tuple(String, PlaceOS::Model::ControlSystem?, PlaceCalendar::Event) mappings.each do |(request, calendar_id, system)| begin results.concat client.list_events(user.email, responses[request]).map { |event| {calendar_id, system, event} } @@ -114,7 +116,7 @@ class Guests < Application ical_uids = Set(String).new metadata_ids = Set(String).new metadata_recurring_ids = Set(String).new - meeting_lookup = {} of String => Tuple(String, PlaceOS::Client::API::Models::System, PlaceCalendar::Event) + meeting_lookup = {} of String => Tuple(String, PlaceOS::Model::ControlSystem, PlaceCalendar::Event) results.each { |(calendar_id, system, event)| if system ical_uid = event.ical_uid.not_nil! @@ -279,14 +281,13 @@ class Guests < Application limit : Int32 = 10, ) : Array(PlaceCalendar::Event) future_only = !include_past - placeos_client = get_placeos_client.systems events = Promise.all(guest.events(future_only, limit).map { |metadata| Promise.defer { begin cal_id = metadata.host_email.not_nil! - system = placeos_client.fetch(metadata.system_id.not_nil!) - sys_cal = system.email.presence + system = find_system!(metadata.system_id.not_nil!) + sys_cal = system.email.to_s.presence event = client.get_event(user.email, id: metadata.event_id.not_nil!, calendar_id: cal_id) if event if sys_cal && client.client_id == :office365 && event.host != sys_cal diff --git a/src/controllers/staff.cr b/src/controllers/staff.cr index 6b2a5fbb..6620380e 100644 --- a/src/controllers/staff.cr +++ b/src/controllers/staff.cr @@ -80,13 +80,10 @@ class Staff < Application id : String, ) : Nil if client.client_id == :office365 - # get current users token - # NOTE: we should move this rest-api function into models - # so we can access it from staff-api. This will improve performance - token = get_placeos_client.users.resource_token + token = current_user.resource_token # make request to the photo endpoint - HTTP::Client.get("https://graph.microsoft.com/v1.0/users/#{id}/photo/$value", headers: HTTP::Headers{ + HTTP::Client.get("https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(id)}/photo/$value", headers: HTTP::Headers{ "Authorization" => "Bearer #{token.token}", }) do |upstream_response| stream(upstream_response) diff --git a/src/controllers/utilities/current-user.cr b/src/controllers/utilities/current-user.cr index 92f7dc04..147db4f8 100644 --- a/src/controllers/utilities/current-user.cr +++ b/src/controllers/utilities/current-user.cr @@ -20,9 +20,14 @@ module Utils::CurrentUser # check for X-API-Key use if token = request.headers["X-API-Key"]? || params["api-key"]? || cookies["api-key"]?.try(&.value) begin - @user_token = user_token = get_placeos_client.apikeys.inspect_jwt + api_key = ::PlaceOS::Model::ApiKey.find_key!(token) + raise Error::Unauthorized.new "API key has expired" if api_key.expired? + user_token = api_key.build_jwt + Log.context.set(api_key_id: api_key.id, api_key_name: api_key.name) @current_user = ::PlaceOS::Model::User.find(user_token.id) - return user_token + return @user_token = user_token + rescue e : Error::Unauthorized + raise e rescue e Log.warn(exception: e) { "bad or unknown X-API-Key" } raise Error::Unauthorized.new "unknown X-API-Key" diff --git a/src/controllers/utilities/multi_tenant.cr b/src/controllers/utilities/multi_tenant.cr index ec8b1a12..4e4bcf57 100644 --- a/src/controllers/utilities/multi_tenant.cr +++ b/src/controllers/utilities/multi_tenant.cr @@ -8,9 +8,9 @@ module Utils::MultiTenant getter client : PlaceCalendar::Client do tenant = current_tenant place_client = if tenant.delegated - # Grab a valid token from RestAPI + # Use the user's SSO resource token begin - token = get_placeos_client.users.resource_token + token = current_user.resource_token tenant.place_calendar_client token.token, token.expires rescue error Log.error(exception: error) { "error obtaining resource token" } diff --git a/src/controllers/utilities/placeos-helpers.cr b/src/controllers/utilities/placeos-helpers.cr index 359a9980..69bb6188 100644 --- a/src/controllers/utilities/placeos-helpers.cr +++ b/src/controllers/utilities/placeos-helpers.cr @@ -1,34 +1,56 @@ -require "placeos" -require "promise" +require "redis-cluster" -# Helper to interact with PlaceOS API +# Helpers for interacting with PlaceOS resources module Utils::PlaceOSHelpers - # Base URL of the PlaceOS instance we are interacting with - PLACE_URI = App::PLACE_URI - PLACE_HOST_HEADER = App::PLACE_HOST_HEADER - - @placeos_client : PlaceOS::Client? = nil - - def get_placeos_client : PlaceOS::Client - @placeos_client ||= if App.running_in_production? - if key = request.headers["X-API-Key"]? || params["api-key"]? || cookies["api-key"]?.try(&.value) - PlaceOS::Client.new( - PLACE_URI, - host_header: PLACE_HOST_HEADER || request.headers["Host"]?, - insecure: ::App::SSL_VERIFY_NONE, - x_api_key: key - ) - else - PlaceOS::Client.new( - PLACE_URI, - token: OAuth2::AccessToken::Bearer.new(acquire_token.not_nil!, nil), - host_header: PLACE_HOST_HEADER || request.headers["Host"]?, - insecure: ::App::SSL_VERIFY_NONE - ) - end - else - PlaceOS::Client.from_environment_user - end + # The authority (PlaceOS domain) this request is being made against + getter current_authority : PlaceOS::Model::Authority? { PlaceOS::Model::Authority.find_by_domain(request.hostname.as(String)) } + + # Systems + # ======= + + # Guests can only access the systems they've been invited to. + # Some routes allow unauthenticated requests (i.e. adding an attendee to a public event) + def find_system!(id : String) : PlaceOS::Model::ControlSystem + if auth_token_present? && user_token.guest_scope? && !user_token.user.roles.includes?(id) + raise Error::Forbidden.new("guest #{user_token.id} cannot access system #{id}") + end + PlaceOS::Model::ControlSystem.find!(id) + end + + def systems_with_emails(emails : Enumerable(String)) : Array(PlaceOS::Model::ControlSystem) + emails = emails.map(&.strip.downcase).uniq! + return [] of PlaceOS::Model::ControlSystem if emails.empty? + PlaceOS::Model::ControlSystem.where(email: emails).to_a + end + + # Signals + # ======= + + @@redis : Redis::Client? = nil + @@redis_lock = Mutex.new + + protected def self.with_redis(&) + @@redis_lock.synchronize do + redis = @@redis ||= Redis::Client.boot(App::REDIS_URL) + yield redis + end + end + + # Publishes JSON data to drivers and frontends listening on the channel, + # both globally and scoped to this domain's authority. + # A failed signal is logged and doesn't fail the request. + def signal(channel : String, payload) : Nil + data = payload.to_json + paths = ["placeos/#{channel}"] + if authority_id = current_authority.try(&.id) + paths << "placeos/#{authority_id}/#{channel}" + end + + Utils::PlaceOSHelpers.with_redis do |redis| + paths.each { |path| redis.publish(path, data) } + end + rescue error + ::App::Log.error(exception: error) { "failed to signal #{channel}" } end # Get the list of local calendars this user has access to @@ -44,59 +66,27 @@ module Utils::PlaceOSHelpers capacity : Int32? = nil, bookable : Bool? = nil, allow_default = false, - ) - calendars = Set.new((calendars || "").split(',').compact_map(&.strip.downcase.presence)) + ) : Hash(String, PlaceOS::Model::ControlSystem?) + calendars = Set.new(split_list(calendars).map(&.downcase)) + zones = split_list(zone_ids) + system_ids = split_list(system_ids) # Create a map of calendar ids to systems # only obtain events for calendars the user has access to - system_calendars = if calendars.size > 0 - if tenant.using_service_account? || tenant.delegated - calendars.each_with_object({} of String => PlaceOS::Client::API::Models::System?) { |calendar, obj| obj[calendar] = nil } - else - user_calendars = Set.new(client.list_calendars(user.email).compact_map(&.id.try &.downcase.presence)) - (calendars & user_calendars).each_with_object({} of String => PlaceOS::Client::API::Models::System?) { |calendar, obj| obj[calendar] = nil } - end - else - {} of String => PlaceOS::Client::API::Models::System? - end - - # Check if we want to grab systems from zones - zones = (zone_ids || "").split(',').compact_map(&.strip.presence).uniq! - if zones.size > 0 - systems = get_placeos_client.systems - - # perform requests in parallel (map-reduce) - Promise.all(zones.map { |zone_id| - Promise.defer { - systems.search( - zone_id: zone_id, - features: features, - capacity: capacity, - bookable: bookable - ) - } - }).get.each do |results| - results.each do |system| - calendar = system.email.presence - next unless calendar - system_calendars[calendar.downcase] = system - end + system_calendars = {} of String => PlaceOS::Model::ControlSystem? + unless calendars.empty? + unless tenant.using_service_account? || tenant.delegated + calendars &= Set.new(client.list_calendars(user.email).compact_map(&.id.try &.downcase.presence)) end + calendars.each { |calendar| system_calendars[calendar] = nil } end - # Check if we want to grab individual systems - system_ids = (system_ids || "").split(',').compact_map(&.strip.presence).uniq! - if system_ids.size > 0 - systems = get_placeos_client.systems - - # perform requests in parallel (map-reduce) - Promise.all(system_ids.map { |system_id| - Promise.defer { systems.fetch(system_id) } - }).get.each do |system| - calendar = system.email.presence - next unless calendar - system_calendars[calendar.downcase] = system - end + # Grab systems from zones and individual systems + systems = zones.empty? ? [] of PlaceOS::Model::ControlSystem : Utils::PlaceOSHelpers.systems_in_zones(zones, split_list(features), capacity, bookable) + systems.concat system_ids.map { |system_id| find_system!(system_id) } + systems.each do |system| + calendar = system.email.to_s.downcase.presence + system_calendars[calendar] = system if calendar end # default to the current user if no params were passed @@ -105,6 +95,24 @@ module Utils::PlaceOSHelpers system_calendars end + # Systems that are in any of the zones and match all of the filters + def self.systems_in_zones(zones : Array(String), features : Array(String), capacity : Int32?, bookable : Bool?) : Array(PlaceOS::Model::ControlSystem) + query = PlaceOS::Model::ControlSystem.where("zones && #{sql_array(zones)}", zones) + query = query.where("features @> #{sql_array(features)}", features) unless features.empty? + query = query.where("capacity >= ?", capacity) if capacity + query = query.where(bookable: bookable) unless bookable.nil? + query.to_a + end + + # placeholders for binding an array of values: "ARRAY[?, ?, ...]::text[]" + protected def self.sql_array(list : Array(String)) : String + "ARRAY[#{list.join(", ") { "?" }}]::text[]" + end + + private def split_list(list : String?) : Array(String) + (list || "").split(',').compact_map(&.strip.presence).uniq! + end + enum Permission None Manage diff --git a/src/models/event.cr b/src/models/event.cr index 0240856e..2a7161a8 100644 --- a/src/models/event.cr +++ b/src/models/event.cr @@ -81,7 +81,7 @@ class PlaceCalendar::Event # This is the resource calendar, it will be moved to one of the attendees property system_id : String? - property system : System? | PlaceOS::Client::API::Models::System? + property system : System? | PlaceOS::Model::ControlSystem? property extension_data : JSON::Any? property recurring_master_id : String?