├── apps ├── api_web │ ├── priv │ │ └── static │ │ │ ├── .gitkeep │ │ │ ├── robots.txt │ │ │ ├── favicon.ico │ │ │ └── images │ │ │ └── api-bg.png │ ├── lib │ │ ├── api_web │ │ │ ├── templates │ │ │ │ ├── client_portal │ │ │ │ │ ├── user │ │ │ │ │ │ ├── new.html.heex │ │ │ │ │ │ ├── forgot_password.html.heex │ │ │ │ │ │ ├── invalid_reset_password.html.heex │ │ │ │ │ │ ├── unenroll_2fa.html.heex │ │ │ │ │ │ ├── enable_2fa.html.heex │ │ │ │ │ │ ├── configure_2fa.html.heex │ │ │ │ │ │ ├── show.html.heex │ │ │ │ │ │ ├── _forgot_password.html.heex │ │ │ │ │ │ ├── edit.html.heex │ │ │ │ │ │ ├── edit_password.html.heex │ │ │ │ │ │ └── reset_password.html.heex │ │ │ │ │ ├── session │ │ │ │ │ │ ├── new.html.heex │ │ │ │ │ │ └── _new.html.heex │ │ │ │ │ └── key │ │ │ │ │ │ ├── edit.html.heex │ │ │ │ │ │ ├── increase.html.heex │ │ │ │ │ │ └── form.html.heex │ │ │ │ ├── admin │ │ │ │ │ ├── session │ │ │ │ │ │ └── login.html.heex │ │ │ │ │ ├── accounts │ │ │ │ │ │ ├── user │ │ │ │ │ │ │ ├── new.html.heex │ │ │ │ │ │ │ └── edit.html.heex │ │ │ │ │ │ └── key │ │ │ │ │ │ │ ├── search.html.heex │ │ │ │ │ │ │ ├── edit.html.heex │ │ │ │ │ │ │ └── index.html.heex │ │ │ │ │ └── layout │ │ │ │ │ │ └── navigation.html.heex │ │ │ │ ├── mfa │ │ │ │ │ └── new.html.heex │ │ │ │ └── shared │ │ │ │ │ ├── totp_form.html.heex │ │ │ │ │ └── login_form.html.heex │ │ │ ├── views │ │ │ │ ├── mfa_view.ex │ │ │ │ ├── shared_view.ex │ │ │ │ ├── admin │ │ │ │ │ ├── layout_view.ex │ │ │ │ │ ├── session │ │ │ │ │ │ └── session_view.ex │ │ │ │ │ └── accounts │ │ │ │ │ │ ├── key_view.ex │ │ │ │ │ │ └── user_view.ex │ │ │ │ ├── client_portal │ │ │ │ │ ├── key_view.ex │ │ │ │ │ ├── layout_view.ex │ │ │ │ │ ├── portal_view.ex │ │ │ │ │ ├── user_view.ex │ │ │ │ │ └── session_view.ex │ │ │ │ ├── agency_view.ex │ │ │ │ ├── line_view.ex │ │ │ │ ├── occupancy_view.ex │ │ │ │ ├── service_view.ex │ │ │ │ ├── error_helpers.ex │ │ │ │ ├── route_pattern_view.ex │ │ │ │ ├── live_facility_view.ex │ │ │ │ └── status_view.ex │ │ │ ├── controllers │ │ │ │ ├── exceptions.ex │ │ │ │ ├── status_controller.ex │ │ │ │ ├── portal │ │ │ │ │ └── portal_controller.ex │ │ │ │ ├── health_controller.ex │ │ │ │ └── mfa_controller.ex │ │ │ ├── plugs │ │ │ │ ├── redirect.ex │ │ │ │ ├── fetch_user.ex │ │ │ │ ├── require_user.ex │ │ │ │ ├── redirect_already_authenticated.ex │ │ │ │ ├── clear_metadata.ex │ │ │ │ ├── require_admin.ex │ │ │ │ ├── experimental_features.ex │ │ │ │ ├── require_2factor.ex │ │ │ │ ├── request_track.ex │ │ │ │ ├── deadline.ex │ │ │ │ ├── rate_limiter.ex │ │ │ │ └── check_for_shutdown.ex │ │ │ ├── view_helpers.ex │ │ │ ├── sentry_event_filter.ex │ │ │ ├── rate_limiter │ │ │ │ ├── limiter.ex │ │ │ │ ├── memcache.ex │ │ │ │ └── memcache │ │ │ │ │ └── supervisor.ex │ │ │ ├── controller_helpers.ex │ │ │ └── endpoint.ex │ │ └── phoenix_html4_compat.ex │ ├── .sobelow-skips │ ├── test │ │ ├── api_web │ │ │ ├── user_test.exs │ │ │ ├── date_helpers_test.exs │ │ │ ├── controllers │ │ │ │ ├── modified_headers_test.exs │ │ │ │ ├── health_controller_test.exs │ │ │ │ ├── status_controller_test.exs │ │ │ │ ├── mfa_controller_test.exs │ │ │ │ └── portal │ │ │ │ │ └── portal_controller_test.exs │ │ │ ├── plugs │ │ │ │ ├── redirect_test.exs │ │ │ │ ├── clear_metadata_test.exs │ │ │ │ ├── require_user_test.exs │ │ │ │ ├── experimental_features_test.exs │ │ │ │ ├── deadline_test.exs │ │ │ │ ├── redirect_already_authenticated_test.exs │ │ │ │ ├── fetch_user_test.exs │ │ │ │ └── require_admin_test.exs │ │ │ ├── protocols_test.exs │ │ │ ├── controller_helpers_test.exs │ │ │ ├── rate_limiter │ │ │ │ └── memcache_test.exs │ │ │ ├── views │ │ │ │ ├── route_view_test.exs │ │ │ │ ├── vehicle_view_test.exs │ │ │ │ └── error_view_test.exs │ │ │ ├── sentry_event_filter_test.exs │ │ │ └── canary_test.exs │ │ ├── support │ │ │ └── fixtures.ex │ │ ├── test_helper.exs │ │ └── api_web_test.exs │ ├── coveralls.json │ ├── .gitignore │ ├── README.md │ └── config │ │ ├── test.exs │ │ └── dev.exs ├── state │ ├── config │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── test │ │ ├── test_helper.exs │ │ └── state │ │ │ ├── matchers_test.exs │ │ │ ├── agency_test.exs │ │ │ ├── stop │ │ │ ├── worker_test.exs │ │ │ ├── list_test.exs │ │ │ └── subscriber_test.exs │ │ │ ├── line_test.exs │ │ │ └── alert │ │ │ ├── hooks_test.exs │ │ │ └── active_period_test.exs │ ├── .gitignore │ ├── coveralls.json │ ├── lib │ │ ├── state │ │ │ ├── facility │ │ │ │ ├── parking.ex │ │ │ │ └── property.ex │ │ │ ├── commuter_rail_occupancy.ex │ │ │ ├── line.ex │ │ │ ├── pagination │ │ │ │ └── offsets.ex │ │ │ ├── stop │ │ │ │ ├── cache.ex │ │ │ │ └── subscriber.ex │ │ │ └── agency.ex │ │ └── logger.ex │ ├── bench │ │ ├── simple.exs │ │ └── schedule_bench.exs │ └── README.md ├── fetch │ ├── config │ │ ├── prod.exs │ │ ├── test.exs │ │ ├── dev.exs │ │ └── config.exs │ ├── test │ │ ├── fixtures │ │ │ └── servername │ │ ├── test_helper.exs │ │ ├── fetch_test.exs │ │ └── fetch │ │ │ └── worker_log_test.exs │ ├── .gitignore │ ├── README.md │ ├── lib │ │ ├── fetch │ │ │ └── app.ex │ │ └── fetch.ex │ └── mix.exs ├── alb_monitor │ ├── config │ │ ├── dev.exs │ │ ├── prod.exs │ │ ├── test.exs │ │ └── config.exs │ ├── test │ │ ├── test_helper.exs │ │ └── support │ │ │ └── mocks.ex │ └── lib │ │ └── alb_monitor.ex ├── events │ ├── test │ │ ├── test_helper.exs │ │ ├── events_test.exs │ │ └── events │ │ │ └── gather_test.exs │ ├── .gitignore │ ├── lib │ │ ├── events │ │ │ ├── application.ex │ │ │ ├── gather.ex │ │ │ └── server.ex │ │ └── events.ex │ ├── README.md │ ├── config │ │ └── config.exs │ └── mix.exs ├── health │ ├── test │ │ ├── test_helper.exs │ │ └── health │ │ │ ├── checker_test.exs │ │ │ └── checkers │ │ │ ├── run_queue_test.exs │ │ │ └── ports_test.exs │ ├── config │ │ └── config.exs │ ├── lib │ │ ├── health │ │ │ ├── checkers │ │ │ │ ├── ports.ex │ │ │ │ ├── run_queue.ex │ │ │ │ ├── real_time.ex │ │ │ │ └── state.ex │ │ │ └── checker.ex │ │ └── health.ex │ ├── .gitignore │ ├── README.md │ └── mix.exs ├── model │ ├── test │ │ ├── test_helper.exs │ │ ├── model_test.exs │ │ ├── model │ │ │ ├── stop_test.exs │ │ │ └── service_test.exs │ │ └── recordable_test.exs │ ├── .gitignore │ ├── lib │ │ ├── model.ex │ │ ├── model │ │ │ ├── feed.ex │ │ │ ├── direction.ex │ │ │ ├── agency.ex │ │ │ ├── wgs84.ex │ │ │ ├── facility │ │ │ │ └── property.ex │ │ │ ├── commuter_rail_occupancy.ex │ │ │ ├── route_pattern.ex │ │ │ ├── vehicle │ │ │ │ └── carriage.ex │ │ │ └── facility.ex │ │ ├── geo_distance.ex │ │ └── recordable.ex │ ├── README.md │ ├── config │ │ └── config.exs │ └── mix.exs ├── parse │ ├── test │ │ ├── test_helper.exs │ │ ├── parse_test.exs │ │ └── parse │ │ │ ├── application_test.exs │ │ │ ├── agency_test.exs │ │ │ ├── directions_test.exs │ │ │ ├── feed_info_test.exs │ │ │ ├── line_test.exs │ │ │ ├── alerts │ │ │ └── calendar_dates_test.exs │ │ │ ├── timezone_test.exs │ │ │ ├── route_patterns_test.exs │ │ │ ├── calendar_test.exs │ │ │ ├── facility │ │ │ └── property_test.exs │ │ │ ├── facility_test.exs │ │ │ ├── trip_updates_test.exs │ │ │ ├── polyline_test.exs │ │ │ └── routes_test.exs │ ├── .gitignore │ ├── lib │ │ ├── parse.ex │ │ └── parse │ │ │ ├── gtfs_rt │ │ │ └── trip_updates.ex │ │ │ ├── vehicle_positions.ex │ │ │ ├── directions.ex │ │ │ ├── feed_info.ex │ │ │ ├── facility │ │ │ └── property.ex │ │ │ ├── helpers.ex │ │ │ ├── multi_route_trips.ex │ │ │ ├── timezone.ex │ │ │ ├── agency.ex │ │ │ ├── calendar_dates.ex │ │ │ ├── time.ex │ │ │ ├── polyline.ex │ │ │ ├── facility.ex │ │ │ ├── route_patterns.ex │ │ │ ├── line.ex │ │ │ ├── simple.ex │ │ │ └── calendar.ex │ ├── README.md │ └── mix.exs ├── state_mediator │ ├── config │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── test │ │ ├── test_helper.exs │ │ ├── state_mediator_test.exs │ │ └── state_mediator │ │ │ └── mediator_log_test.exs │ ├── lib │ │ ├── firebase.ex │ │ └── state_mediator │ │ │ └── mqtt_mediator │ │ │ └── handler.ex │ ├── README.md │ └── .gitignore └── api_accounts │ ├── test │ ├── test_helper.exs │ └── support │ │ ├── api_accounts │ │ └── test │ │ │ └── database_case.ex │ │ ├── test_migrations.ex │ │ └── test_tables.ex │ ├── coveralls.json │ ├── config │ ├── prod.exs │ ├── dev.exs │ ├── test.exs │ └── config.exs │ ├── lib │ ├── api_accounts │ │ ├── mailer.ex │ │ ├── no_results_error.ex │ │ ├── migrations │ │ │ ├── schema_migration.ex │ │ │ └── migrations.ex │ │ ├── application.ex │ │ └── key.ex │ ├── encoders.ex │ └── decoders.ex │ └── .gitignore ├── .codecov.yml ├── mosquitto └── mosquitto.conf ├── .tool-versions ├── CODEOWNERS ├── deploy.sh ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── docker.yml │ ├── asana.yml │ ├── deploy-dev-green.yml │ ├── deploy-prod.yml │ ├── deploy-dev.yml │ └── deploy-dev-blue.yml └── dependabot.yml ├── .formatter.exs ├── semaphore ├── Dockerrun.aws.json └── build_push.sh ├── rel ├── .ebextensions │ ├── 01ulimit.config │ └── 02nginx.config ├── vm.args.eex └── env.sh.eex ├── .dialyzer.ignore-warnings ├── .dockerignore ├── docker-compose.yml ├── load_tests └── pyproject.toml ├── codecov.yml ├── AUTHORS ├── .gitignore ├── config └── config.exs ├── LICENSE └── Dockerfile /apps/api_web/priv/static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/api_web/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/state/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/fetch/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/fetch/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/state/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/state/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/alb_monitor/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/alb_monitor/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/events/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/fetch/test/fixtures/servername: -------------------------------------------------------------------------------- 1 | "cache file" 2 | -------------------------------------------------------------------------------- /apps/fetch/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/health/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/model/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/parse/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/state/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/state_mediator/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /apps/state_mediator/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: no 3 | -------------------------------------------------------------------------------- /apps/alb_monitor/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/api_accounts/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/new.html.heex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mosquitto/mosquitto.conf: -------------------------------------------------------------------------------- 1 | listener 1883 2 | allow_anonymous true 3 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/session/new.html.heex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/forgot_password.html.heex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/events/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /apps/fetch/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /apps/model/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /apps/parse/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /apps/state/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /apps/state_mediator/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(exclude: [:integration]) 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.3-otp-27 2 | erlang 27.2 3 | python 3.9.16 4 | poetry 1.7.0 5 | -------------------------------------------------------------------------------- /apps/api_accounts/coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support" 4 | ] 5 | } -------------------------------------------------------------------------------- /apps/fetch/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :fetch, Fetch, cache_directory: "cache/" 4 | -------------------------------------------------------------------------------- /apps/api_accounts/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :api_accounts, migrate_on_start: true 4 | -------------------------------------------------------------------------------- /apps/model/test/model_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ModelTest do 2 | use ExUnit.Case 3 | doctest Model 4 | end 5 | -------------------------------------------------------------------------------- /apps/parse/test/parse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ParseTest do 2 | use ExUnit.Case 3 | doctest Parse 4 | end 5 | -------------------------------------------------------------------------------- /apps/alb_monitor/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :alb_monitor, ex_aws: FakeAws, http: FakeHTTP 4 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/mfa_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.MFAView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/events/test/events_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EventsTest do 2 | use ExUnit.Case 3 | doctest Events 4 | end 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # The Transit Data team is the default owner for the entire codebase. 2 | * @mbta/transit-data 3 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/shared_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.SharedView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbta/api/HEAD/apps/api_web/priv/static/favicon.ico -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/admin/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Admin.LayoutView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/priv/static/images/api-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbta/api/HEAD/apps/api_web/priv/static/images/api-bg.png -------------------------------------------------------------------------------- /apps/alb_monitor/test/support/mocks.ex: -------------------------------------------------------------------------------- 1 | Mox.defmock(FakeAws, for: ExAws.Behaviour) 2 | Mox.defmock(FakeHTTP, for: HTTPoison.Base) 3 | -------------------------------------------------------------------------------- /apps/api_web/.sobelow-skips: -------------------------------------------------------------------------------- 1 | 2 | 89AB244CFEA58A837123CC326E49CD13 3 | C925E5F931492531D9372392664AE12C 4 | CB8E15060F63E711108608C13BA30278 -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/admin/session/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Admin.SessionView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/client_portal/key_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ClientPortal.KeyView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/admin/accounts/key_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Admin.Accounts.KeyView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/client_portal/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ClientPortal.LayoutView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/client_portal/portal_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ClientPortal.PortalView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/client_portal/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ClientPortal.UserView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.UserTest do 2 | use ExUnit.Case, async: true 3 | doctest ApiWeb.User 4 | end 5 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/client_portal/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ClientPortal.SessionView do 2 | use ApiWeb.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /apps/state/test/state/matchers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.MatchersTest do 2 | use ExUnit.Case, async: true 3 | doctest State.Matchers 4 | end 5 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/agency_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.AgencyView do 2 | use ApiWeb.Web, :api_view 3 | 4 | attributes([:agency_name]) 5 | end 6 | -------------------------------------------------------------------------------- /apps/model/lib/model.ex: -------------------------------------------------------------------------------- 1 | defmodule Model do 2 | @moduledoc """ 3 | 4 | Structs shared between multiple applications in the API. 5 | 6 | """ 7 | end 8 | -------------------------------------------------------------------------------- /apps/state/coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_stop_words": [ 3 | "no cover" 4 | ], 5 | "skip_files": [ 6 | "test/support" 7 | ] 8 | } 9 | 10 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | if [ ! -f $VIRTUAL_ENV/bin/activate ]; then 4 | . aws/bin/activate 5 | fi 6 | 7 | ./build.sh 8 | eb deploy --staged $* 9 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/date_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DateHelpersTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | doctest DateHelpers 5 | end 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Summary of changes 2 | 3 | **Asana Ticket:** [TICKET_NAME](TICKET_LINK) 4 | 5 | [Please include a brief description of what was changed] 6 | -------------------------------------------------------------------------------- /apps/api_accounts/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :api_accounts, table_prefix: "DEV" 4 | 5 | config :api_accounts, ApiAccounts.Mailer, adapter: Bamboo.LocalAdapter 6 | -------------------------------------------------------------------------------- /apps/api_web/coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_stop_words": [ 3 | "no cover", 4 | "attributes\\(\\[" 5 | ], 6 | "skip_files": [ 7 | "test/support" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /apps/model/test/model/stop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Model.StopTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | alias Model.Stop 5 | import Stop 6 | 7 | doctest Stop 8 | end 9 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/api_accounts/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAccounts.Mailer do 2 | @moduledoc """ 3 | Email client to send emails. 4 | """ 5 | use Bamboo.Mailer, otp_app: :api_accounts 6 | end 7 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/controllers/exceptions.ex: -------------------------------------------------------------------------------- 1 | defimpl Plug.Exception, for: ApiAccounts.NoResultsError do 2 | def status(_expection), do: 404 3 | 4 | def actions(_exception), do: [] 5 | end 6 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/session/login.html.heex: -------------------------------------------------------------------------------- 1 | {render(ApiWeb.SharedView, "login_form.html", 2 | action: admin_session_path(@conn, :create), 3 | changeset: @changeset 4 | )} 5 | -------------------------------------------------------------------------------- /apps/model/test/model/service_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Model.ServiceTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | alias Model.Service 5 | import Model.Service 6 | doctest Model.Service 7 | end 8 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "apps/*/{config,lib,test}/**/*.{heex,ex,exs}"], 4 | import_deps: [:phoenix], 5 | plugins: [Phoenix.LiveView.HTMLFormatter], 6 | rename_deprecated_at: "1.14.3" 7 | ] 8 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: [push] 4 | 5 | jobs: 6 | docker: 7 | name: Build Docker image 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | - run: docker build . 12 | -------------------------------------------------------------------------------- /semaphore/Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Image": { 4 | "Name": "DOCKER_REPO:git-GITHASH", 5 | "Update": "true" 6 | }, 7 | "Ports": [ 8 | { 9 | "ContainerPort": "4000" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/invalid_reset_password.html.heex: -------------------------------------------------------------------------------- 1 |

Reset Password

2 | 3 |
4 |

The token you've provided is invalid.

5 |
6 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/accounts/user/new.html.heex: -------------------------------------------------------------------------------- 1 |

New User

2 | 3 | {render("form.html", 4 | changeset: @changeset, 5 | action: admin_user_path(@conn, :create) 6 | )} 7 | 8 | {link("Back", to: admin_user_path(@conn, :index))} 9 | -------------------------------------------------------------------------------- /apps/parse/lib/parse.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse do 2 | @moduledoc """ 3 | 4 | Behaviour for all our parsers. They should take a binary and return an 5 | Enumerable of whatever they're parsing. 6 | 7 | """ 8 | @callback parse(binary) :: Enumerable.t() 9 | end 10 | -------------------------------------------------------------------------------- /rel/.ebextensions/01ulimit.config: -------------------------------------------------------------------------------- 1 | 2 | files: 3 | "/etc/security/limits.d/docker": 4 | mode: "00644" 5 | owner: "root" 6 | group: "root" 7 | content: | 8 | * hard nofile 200000 9 | * soft nofile 200000 10 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/mfa/new.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | {render(ApiWeb.SharedView, "totp_form.html", 4 | action: mfa_path(@conn, :create), 5 | changeset: @changeset 6 | )} 7 |
8 |
9 | -------------------------------------------------------------------------------- /.dialyzer.ignore-warnings: -------------------------------------------------------------------------------- 1 | Expression produces a value of type [{'app',_} | {'body',binary()} | {'spec_url',binary() | maybe_improper_list(binary() | maybe_improper_list(any(),binary() | []) | char(),binary() | [])} | {'swagger_file_path',binary()},...], but this value is unmatched 2 | -------------------------------------------------------------------------------- /apps/alb_monitor/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | config :alb_monitor, ex_aws: ExAws, http: HTTPoison 6 | 7 | import_config "#{config_env()}.exs" 8 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/accounts/user/edit.html.heex: -------------------------------------------------------------------------------- 1 |

Edit User

2 | 3 | {render("form.html", 4 | changeset: @changeset, 5 | action: admin_user_path(@conn, :update, @user), 6 | method: :put 7 | )} 8 | 9 | {link("Back", to: admin_user_path(@conn, :index))} 10 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/unenroll_2fa.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

Validate your TOTP code to unenroll from 2FA.

3 | {render(ApiWeb.SharedView, "totp_form.html", 4 | action: user_path(@conn, :disable_2fa), 5 | changeset: @changeset 6 | )} 7 |
8 | -------------------------------------------------------------------------------- /apps/api_accounts/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :bcrypt_elixir, log_rounds: 4 4 | 5 | config :api_accounts, table_prefix: "TEST" 6 | 7 | config :ex_aws, 8 | access_key_id: "TestAccessKey", 9 | secret_access_key: "TestSecretKey" 10 | 11 | config :api_accounts, ApiAccounts.Mailer, adapter: Bamboo.TestAdapter 12 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/accounts/key/search.html.heex: -------------------------------------------------------------------------------- 1 | <%= form_for @conn, @action, [as: :search], fn f -> %> 2 |
3 | {text_input(f, :key, class: "form-control")} 4 |
5 | 6 |
7 | {submit("Search", class: "btn btn-primary")} 8 |
9 | <% end %> 10 | -------------------------------------------------------------------------------- /apps/state/lib/state/facility/parking.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Facility.Parking do 2 | @moduledoc """ 3 | Maintains the current state of the parking information (coming from IBM). 4 | """ 5 | use State.Server, 6 | recordable: Model.Facility.Property, 7 | indices: [:facility_id, :name], 8 | parser: Parse.Facility.Parking 9 | end 10 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/encoders.ex: -------------------------------------------------------------------------------- 1 | defimpl ExAws.Dynamo.Encodable, for: NaiveDateTime do 2 | def encode(datetime, _) do 3 | %{"S" => NaiveDateTime.to_iso8601(datetime)} 4 | end 5 | end 6 | 7 | defimpl ExAws.Dynamo.Encodable, for: DateTime do 8 | def encode(datetime, _) do 9 | %{"S" => DateTime.to_iso8601(datetime)} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *~ 2 | Mnesia* 3 | _build 4 | deps 5 | apps/*/node_modules 6 | apps/*/test 7 | cache 8 | rel 9 | !rel/env.sh.eex 10 | !rel/vm.args.eex 11 | erl_crash.dump 12 | build.sh 13 | deploy.sh 14 | api-build.zip 15 | DynamoDBLocal* 16 | Dockerfile 17 | 18 | # Elastic Beanstalk Files 19 | .elasticbeanstalk/* 20 | aws 21 | .git 22 | .gitignore 23 | .dockerignore 24 | -------------------------------------------------------------------------------- /apps/state/lib/state/facility/property.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Facility.Property do 2 | @moduledoc """ 3 | Manages the list of elevators/escalators. 4 | """ 5 | use State.Server, 6 | fetched_filename: "facilities_properties.txt", 7 | recordable: Model.Facility.Property, 8 | indices: [:facility_id, :name], 9 | parser: Parse.Facility.Property 10 | end 11 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/admin/accounts/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Admin.Accounts.UserView do 2 | use ApiWeb.Web, :view 3 | 4 | @doc """ 5 | Gets a pending key request if one is present. 6 | """ 7 | @spec key_request([ApiAccounts.Key.t()]) :: ApiAccounts.Key.t() | nil 8 | def key_request(keys) do 9 | Enum.find(keys, &(not &1.approved)) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/model/lib/model/feed.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.Feed do 2 | @moduledoc """ 3 | Metadata about the current GTFS file. 4 | """ 5 | 6 | defstruct [:name, :start_date, :end_date, :version] 7 | 8 | @type t :: %__MODULE__{ 9 | name: String.t(), 10 | start_date: Date.t(), 11 | end_date: Date.t(), 12 | version: String.t() 13 | } 14 | end 15 | -------------------------------------------------------------------------------- /apps/state_mediator/lib/firebase.ex: -------------------------------------------------------------------------------- 1 | defmodule StateMediator.Firebase do 2 | @moduledoc """ 3 | Use `goth` to fetch Firebase oauth tokens to construct a URL. 4 | """ 5 | 6 | @spec url(module(), String.t()) :: String.t() 7 | def url(goth_mod, base_url) do 8 | {:ok, goth_token} = Goth.fetch(goth_mod) 9 | base_url <> "?access_token=" <> goth_token.token 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/controllers/modified_headers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ModifiedHeadersTest do 2 | use ApiWeb.ConnCase 3 | 4 | test "modified headers are added to the response", %{conn: conn} do 5 | State.Stop.new_state([%Model.Stop{id: "stop"}]) 6 | 7 | conn = get(conn, stop_path(conn, :index)) 8 | assert [_] = get_resp_header(conn, "last-modified") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/gtfs_rt/trip_updates.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.GtfsRt.TripUpdates do 2 | @moduledoc """ 3 | Parser for the GTFS-RT TripUpdates JSON output 4 | 5 | We formerly parsed GTFS-RT protobufs in this module as well 6 | """ 7 | @behaviour Parse 8 | use Timex 9 | 10 | def parse("{" <> _ = blob) do 11 | Parse.GtfsRt.TripUpdatesEnhancedJson.parse(blob) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/state/lib/state/commuter_rail_occupancy.ex: -------------------------------------------------------------------------------- 1 | defmodule State.CommuterRailOccupancy do 2 | @moduledoc """ 3 | Manages the expected level of crowding of Commuter Rail trains, provided 4 | by the Keolis firebase feed. 5 | """ 6 | 7 | use State.Server, 8 | indices: [:trip_name], 9 | parser: Parse.CommuterRailOccupancies, 10 | recordable: Model.CommuterRailOccupancy 11 | end 12 | -------------------------------------------------------------------------------- /apps/events/lib/events/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Events.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | children = [ 7 | {Registry, keys: :duplicate, name: Events.Registry} 8 | ] 9 | 10 | Supervisor.start_link( 11 | children, 12 | strategy: :one_for_one, 13 | name: Events.Supervisor 14 | ) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/fetch/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | config :fetch, Fetch, cache_directory: nil 6 | 7 | # Import environment specific config. This must remain at the bottom 8 | # of this file so it overrides the configuration defined above. 9 | import_config "#{config_env()}.exs" 10 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/api_accounts/no_results_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAccounts.NoResultsError do 2 | @moduledoc """ 3 | Error representing when no results were found when they were expected. 4 | """ 5 | 6 | defexception [:message] 7 | 8 | @doc """ 9 | Callback implementation for `Exception.exception/1`. 10 | """ 11 | def exception(message) do 12 | %__MODULE__{message: message} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/health/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | config :health, 6 | checkers: [ 7 | Health.Checkers.State, 8 | Health.Checkers.RunQueue, 9 | Health.Checkers.RealTime, 10 | Health.Checkers.Ports 11 | ] 12 | 13 | config :health, Health.Checkers.Ports, max_ports: 13_000 14 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/api_accounts/migrations/schema_migration.ex: -------------------------------------------------------------------------------- 1 | defprotocol ApiAccounts.Migrations.SchemaMigration do 2 | @moduledoc """ 3 | Protocol for migrating a DynamoDB item to different versions. 4 | """ 5 | @fallback_to_any true 6 | 7 | def migrate(item, current_version, target_version) 8 | end 9 | 10 | defimpl ApiAccounts.Migrations.SchemaMigration, for: Any do 11 | def migrate(item, _, _), do: item 12 | end 13 | -------------------------------------------------------------------------------- /apps/state/lib/state/line.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Line do 2 | @moduledoc """ 3 | 4 | Stores and indexes `Model.Line.t` from `lines.txt`. 5 | 6 | """ 7 | use State.Server, 8 | fetched_filename: "lines.txt", 9 | recordable: Model.Line, 10 | indices: [:id], 11 | parser: Parse.Line 12 | 13 | def by_id(id) do 14 | case super(id) do 15 | [] -> nil 16 | [line] -> line 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/health/lib/health/checkers/ports.ex: -------------------------------------------------------------------------------- 1 | defmodule Health.Checkers.Ports do 2 | @moduledoc """ 3 | Health check which makes sure there are not too many ports open. 4 | """ 5 | 6 | defp port_count do 7 | length(:erlang.ports()) 8 | end 9 | 10 | def current do 11 | [ports: port_count()] 12 | end 13 | 14 | def healthy? do 15 | port_count() < Application.get_env(:health, __MODULE__)[:max_ports] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/model/lib/model/direction.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.Direction do 2 | shared_doc = """ 3 | The direction ID (`0` and `1`) used by 4 | [GTFS `trips.txt`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#tripstxt) has no defined 5 | human-readable interpretation: it depends on each route in MBTA's system. 6 | """ 7 | 8 | @moduledoc shared_doc 9 | 10 | @typedoc shared_doc 11 | @type id :: 0 | 1 12 | end 13 | -------------------------------------------------------------------------------- /apps/state/lib/state/pagination/offsets.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Pagination.Offsets do 2 | @moduledoc """ 3 | Holds pagination offsets for the first, last, next, and previous pages. 4 | """ 5 | 6 | defstruct [:next, :prev, :first, :last] 7 | 8 | @type t :: %__MODULE__{ 9 | next: pos_integer | nil, 10 | prev: non_neg_integer | nil, 11 | first: 0, 12 | last: non_neg_integer 13 | } 14 | end 15 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/redirect.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.Redirect do 2 | @moduledoc """ 3 | Simple plug to assist in redirects. 4 | 5 | ## Example Router Usage 6 | 7 | get "/", ApiWeb.Redirect, to: "/other_path" 8 | 9 | """ 10 | import Plug.Conn 11 | 12 | def init(opts), do: opts 13 | 14 | def call(conn, opts) do 15 | conn 16 | |> Phoenix.Controller.redirect(opts) 17 | |> halt() 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/state/lib/state/stop/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Stop.Cache do 2 | @moduledoc """ 3 | Caches `Model.Stop.t` by `Model.Stop.id` and `Model.Stop.t` `parent_station` 4 | """ 5 | 6 | use State.Server, 7 | indices: [:id, :parent_station, :location_type, :vehicle_type], 8 | recordable: Model.Stop 9 | 10 | def by_id(id) do 11 | case super(id) do 12 | [] -> nil 13 | [stop] -> stop 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | dynamodb-local: 4 | command: "-jar DynamoDBLocal.jar -sharedDb -inMemory" 5 | image: "amazon/dynamodb-local:2.0.0" 6 | container_name: dynamodb-local 7 | ports: 8 | - "8000:8000" 9 | mosquitto: 10 | image: "eclipse-mosquitto:2.0" 11 | container_name: mosquitto 12 | ports: 13 | - "1883:1883" 14 | volumes: 15 | - ./mosquitto:/mosquitto/config/ 16 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/controllers/health_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.HealthControllerTest do 2 | use ApiWeb.ConnCase 3 | 4 | setup %{conn: conn} do 5 | {:ok, conn: put_req_header(conn, "accept", "application/json")} 6 | end 7 | 8 | test "defaults to 503", %{conn: conn} do 9 | State.StopsOnRoute.update!() 10 | conn = get(conn, health_path(conn, :index)) 11 | assert json_response(conn, 503) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/vehicle_positions.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.VehiclePositions do 2 | @moduledoc """ 3 | 4 | Parser for the VehiclePositions.pb GTFS-RT file. 5 | 6 | """ 7 | @behaviour Parse 8 | 9 | def parse(<<31, 139, _::binary>> = blob) do 10 | # gzip encoded 11 | blob 12 | |> :zlib.gunzip() 13 | |> parse 14 | end 15 | 16 | def parse("{" <> _ = blob) do 17 | Parse.VehiclePositionsJson.parse(blob) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /load_tests/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "load_tests" 3 | version = "0.1.0" 4 | description = "Load testing API using Locust" 5 | authors = ["Ian Westcott "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | locust = "^1.5.3" 10 | flake8 = "^3.9.2" 11 | black = "^24.3" 12 | 13 | [tool.poetry.dev-dependencies] 14 | 15 | [build-system] 16 | requires = ["poetry-core>=1.0.0"] 17 | build-backend = "poetry.core.masonry.api" 18 | -------------------------------------------------------------------------------- /apps/api_web/lib/phoenix_html4_compat.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTML4Compat do 2 | @moduledoc """ 3 | Replace `use Phoenix.HTML` with `use PhoenixHTML4Compat` for compatibility 4 | with phoenix_html 4.0+. 5 | 6 | https://hexdocs.pm/phoenix_html/changelog.html#v4-0-0-2023-12-19 7 | """ 8 | defmacro __using__(_) do 9 | quote do 10 | import Phoenix.HTML 11 | import Phoenix.HTML.Form 12 | use PhoenixHTMLHelpers 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/parse/test/parse/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.ApplicationTest do 2 | @moduledoc false 3 | use ExUnit.Case 4 | 5 | describe "start/2" do 6 | setup do 7 | :ok = Application.stop(:parse) 8 | 9 | on_exit(fn -> 10 | Application.ensure_all_started(:parse) 11 | end) 12 | 13 | :ok 14 | end 15 | 16 | test "starts the application" do 17 | assert Application.start(:parse, :transient) == :ok 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/health/test/health/checker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Health.CheckerTest do 2 | use ExUnit.Case, async: true 3 | import Health.Checker 4 | 5 | describe "current/0" do 6 | test "returns a non empty keyword list" do 7 | actual = current() 8 | assert Keyword.keyword?(actual) 9 | refute actual == [] 10 | end 11 | end 12 | 13 | describe "healthy?" do 14 | test "returns a boolean" do 15 | assert is_boolean(healthy?()) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/redirect_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RedirectTest do 2 | use ApiWeb.ConnCase 3 | alias ApiWeb.Plugs.Redirect 4 | 5 | test "init/1" do 6 | assert Redirect.init([]) == [] 7 | end 8 | 9 | test "call/2", %{conn: conn} do 10 | conn = 11 | conn 12 | |> bypass_through() 13 | |> get("/") 14 | |> Redirect.call(to: "/test") 15 | 16 | assert redirected_to(conn) == "/test" 17 | assert conn.halted 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/state/bench/simple.exs: -------------------------------------------------------------------------------- 1 | defmodule State.SimpleBench do 2 | use Benchfella 3 | 4 | @schedule %Model.Schedule{ 5 | trip_id: "trip", 6 | stop_id: "stop", 7 | position: :first} 8 | 9 | def setup_all do 10 | State.Schedule.new_state([@schedule]) 11 | end 12 | 13 | bench "by_trip_id" do 14 | State.Schedule.by_trip_id("trip") == [@schedule] 15 | end 16 | 17 | bench "match" do 18 | State.Schedule.match(%{trip_id: "trip"}) == [@schedule] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/fetch_user.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.FetchUser do 2 | @moduledoc """ 3 | Fetches a user_id stored in the session and assigns the user. 4 | """ 5 | import Plug.Conn 6 | 7 | def init(opts), do: opts 8 | 9 | def call(conn, _) do 10 | case get_session(conn, :user_id) do 11 | nil -> 12 | conn 13 | 14 | user_id -> 15 | user = ApiAccounts.get_user!(user_id) 16 | assign(conn, :user, user) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/fetch/README.md: -------------------------------------------------------------------------------- 1 | # Fetch 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add fetch to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:fetch, "~> 0.0.1"}] 13 | end 14 | 15 | 2. Ensure fetch is started before your application: 16 | 17 | def application do 18 | [applications: [:fetch]] 19 | end 20 | 21 | -------------------------------------------------------------------------------- /apps/model/README.md: -------------------------------------------------------------------------------- 1 | # Model 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add model to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:model, "~> 0.0.1"}] 13 | end 14 | 15 | 2. Ensure model is started before your application: 16 | 17 | def application do 18 | [applications: [:model]] 19 | end 20 | 21 | -------------------------------------------------------------------------------- /apps/parse/README.md: -------------------------------------------------------------------------------- 1 | # Parse 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add parse to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:parse, "~> 0.0.1"}] 13 | end 14 | 15 | 2. Ensure parse is started before your application: 16 | 17 | def application do 18 | [applications: [:parse]] 19 | end 20 | 21 | -------------------------------------------------------------------------------- /apps/state/README.md: -------------------------------------------------------------------------------- 1 | # State 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add state to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:state, "~> 0.0.1"}] 13 | end 14 | 15 | 2. Ensure state is started before your application: 16 | 17 | def application do 18 | [applications: [:state]] 19 | end 20 | 21 | -------------------------------------------------------------------------------- /apps/events/README.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add events to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:events, "~> 0.0.1"}] 13 | end 14 | 15 | 2. Ensure events is started before your application: 16 | 17 | def application do 18 | [applications: [:events]] 19 | end 20 | 21 | -------------------------------------------------------------------------------- /apps/health/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/health/test/health/checkers/run_queue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Health.Checkers.RunQueueTest do 2 | use ExUnit.Case 3 | import Health.Checkers.RunQueue 4 | 5 | describe "healthy?/0" do 6 | test "always returns true" do 7 | assert healthy?() == true 8 | end 9 | end 10 | 11 | describe "current/0" do 12 | test "returns the current run queue size" do 13 | [run_queue: size] = current() 14 | assert is_integer(size) 15 | assert size >= 0 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/api_web/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/state_mediator/README.md: -------------------------------------------------------------------------------- 1 | # StateMediator 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | To use in another OTP app in this umbrella project 8 | 9 | 1. Add `state_mediator` to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:state, in_umbrella: true}] 13 | end 14 | 15 | 2. Ensure `state_mediator` is started before your application: 16 | 17 | def application do 18 | [applications: [:state_mediator]] 19 | end 20 | -------------------------------------------------------------------------------- /apps/api_accounts/test/support/api_accounts/test/database_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAccounts.Test.DatabaseCase do 2 | @moduledoc """ 3 | Template for tests that rely on DynamoDB interactions. 4 | """ 5 | use ExUnit.CaseTemplate 6 | 7 | setup do 8 | ApiAccounts.Dynamo.delete_all_tables() 9 | {:ok, _} = ApiAccounts.Dynamo.create_table(ApiAccounts.User) 10 | {:ok, _} = ApiAccounts.Dynamo.create_table(ApiAccounts.Key) 11 | on_exit(fn -> ApiAccounts.Dynamo.delete_all_tables() end) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/enable_2fa.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

Scan the QR code below with your authenticator app.

3 | QR code 4 |

Alternatively you can use the following secret:
5 | {@secret}

6 | 7 |

Enter the code from your authenticator app below to confirm

8 | {render(ApiWeb.SharedView, "totp_form.html", 9 | action: user_path(@conn, :enable_2fa), 10 | changeset: @changeset 11 | )} 12 |
13 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/protocols_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ProtocolsTest do 2 | use ExUnit.Case, async: true 3 | import ApiAccounts.Changeset 4 | 5 | @mod Phoenix.HTML.FormData.ApiAccounts.Changeset 6 | 7 | test "to_form/4" do 8 | changeset = 9 | %ApiAccounts.User{} 10 | |> cast(%{}, ~w()) 11 | |> validate_required(:name) 12 | 13 | assert_raise ArgumentError, fn -> 14 | @mod.to_form(changeset, @mod.to_form(changeset, as: "test"), :name, []) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/state_mediator/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/shared/totp_form.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for @changeset, @action, [method: :post], fn f -> %> 3 | <.form_group form={f} field={:totp_code}> 4 | {label(f, :totp_code, "TOTP Code", class: "control-label")} 5 | {text_input(f, :totp_code, placeholder: "Code", class: "form-control")} 6 | {error_tag(f, :totp_code, "TOTP Code")} 7 | 8 | {submit("Validate TOTP", class: "btn btn-primary")} 9 | <% end %> 10 |
11 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/directions.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Directions do 2 | @moduledoc """ 3 | Parser for GTFS directions.txt 4 | """ 5 | use Parse.Simple 6 | defstruct [:route_id, :direction_id, :direction, :direction_destination] 7 | 8 | def parse_row(row) do 9 | %__MODULE__{ 10 | route_id: copy(row["route_id"]), 11 | direction_id: copy(row["direction_id"]), 12 | direction: copy(row["direction"]), 13 | direction_destination: copy(row["direction_destination"]) 14 | } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/state/lib/state/agency.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Agency do 2 | @moduledoc """ 3 | Stores and indexes `Model.Agency.t` from `agency.txt`. 4 | """ 5 | 6 | use State.Server, 7 | indices: [:id], 8 | fetched_filename: "agency.txt", 9 | parser: Parse.Agency, 10 | recordable: Model.Agency 11 | 12 | alias Model.Agency 13 | 14 | @spec by_id(Agency.id()) :: Agency.t() | nil 15 | def by_id(id) do 16 | case super(id) do 17 | [] -> nil 18 | [agency] -> agency 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/health/README.md: -------------------------------------------------------------------------------- 1 | # Health 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add `health` to your list of dependencies in `mix.exs`: 10 | 11 | ```elixir 12 | def deps do 13 | [{:health, "~> 0.1.0"}] 14 | end 15 | ``` 16 | 17 | 2. Ensure `health` is started before your application: 18 | 19 | ```elixir 20 | def application do 21 | [applications: [:health]] 22 | end 23 | ``` 24 | 25 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/feed_info.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.FeedInfo do 2 | @moduledoc false 3 | use Parse.Simple 4 | alias Model.Feed 5 | 6 | def parse_row(row) do 7 | %Feed{ 8 | name: copy(row["feed_publisher_name"]), 9 | version: copy(row["feed_version"]), 10 | start_date: parse_date(row["feed_start_date"]), 11 | end_date: parse_date(row["feed_end_date"]) 12 | } 13 | end 14 | 15 | defp parse_date(str) do 16 | str 17 | |> Timex.parse!("{YYYY}{0M}{0D}") 18 | |> NaiveDateTime.to_date() 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/api_accounts/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAccounts.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | _ = 7 | if Application.get_env(:api_accounts, :migrate_on_start) do 8 | ApiAccounts.Dynamo.migrate() 9 | end 10 | 11 | Supervisor.start_link( 12 | [ 13 | :hackney_pool.child_spec(:ex_aws_pool, []), 14 | ApiAccounts.Keys 15 | ], 16 | strategy: :one_for_one, 17 | name: ApiAccounts.Supervisor 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | bot: mbtace 3 | notify: 4 | require_ci_to_pass: true 5 | comment: 6 | behavior: default 7 | layout: header, diff 8 | require_changes: false 9 | coverage: 10 | precision: 2 11 | range: 12 | - 70.0 13 | - 100.0 14 | round: down 15 | status: 16 | changes: false 17 | patch: true 18 | project: false 19 | parsers: 20 | gcov: 21 | branch_detection: 22 | conditional: true 23 | loop: true 24 | macro: false 25 | method: false 26 | javascript: 27 | enable_partials: false 28 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/require_user.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RequireUser do 2 | @moduledoc """ 3 | Requires a user to be assigned to the Conn. 4 | """ 5 | import Plug.Conn 6 | import Phoenix.Controller, only: [redirect: 2] 7 | def init(opts), do: opts 8 | 9 | def call(conn, _) do 10 | case conn.assigns[:user] do 11 | %ApiAccounts.User{} -> 12 | conn 13 | 14 | nil -> 15 | conn 16 | |> redirect(to: ApiWeb.Router.Helpers.session_path(conn, :new)) 17 | |> halt() 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /apps/api_accounts/test/support/test_migrations.ex: -------------------------------------------------------------------------------- 1 | defimpl ApiAccounts.Migrations.SchemaMigration, for: ApiAccounts.Test.MigrationModel do 2 | alias ApiAccounts.Test.MigrationModel 3 | 4 | def migrate(item, 0, 1) do 5 | date = NaiveDateTime.from_iso8601!(item.date) 6 | %MigrationModel{item | date: date, schema_version: 1} 7 | end 8 | 9 | def migrate(item, 1, 2) do 10 | name = String.upcase(item.name) 11 | date = DateTime.from_naive!(item.date, "Etc/UTC") 12 | %MigrationModel{item | name: name, date: date, schema_version: 2} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/state/test/state/agency_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.AgencyTest do 2 | use ExUnit.Case 3 | alias Model.Agency 4 | 5 | setup do 6 | State.Agency.new_state([]) 7 | end 8 | 9 | test "returns nil for unknown agency" do 10 | assert State.Agency.by_id("1") == nil 11 | end 12 | 13 | test "it can add an agency and query it" do 14 | agency = %Agency{ 15 | id: "1", 16 | agency_name: "Made-Up Transit Agency" 17 | } 18 | 19 | State.Agency.new_state([agency]) 20 | 21 | assert State.Agency.by_id("1") == agency 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/session/_new.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | {render(ApiWeb.SharedView, "login_form.html", 4 | action: session_path(@conn, :create), 5 | changeset: @changeset 6 | )} 7 | 8 |

9 | Don't have an account? {link("Create an account", to: user_path(@conn, :new))}. 10 |

11 | 12 |

13 | {link("Forgot your password?", to: user_path(@conn, :forgot_password))} 14 |

15 |
16 |
17 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | +Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | 13 | -heart 14 | 15 | -smp auto 16 | 17 | # from https://erlangforums.com/t/vm-tuning-guide/1945/5?u=astonj 18 | +sbwt none 19 | +sbwtdcpu none 20 | +sbwtdio none 21 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/line_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.LineView do 2 | use ApiWeb.Web, :api_view 3 | 4 | location(:line_location) 5 | 6 | def line_location(line, conn), do: line_path(conn, :show, line.id) 7 | 8 | # no cover 9 | attributes([ 10 | :short_name, 11 | :long_name, 12 | :color, 13 | :text_color, 14 | :sort_order 15 | ]) 16 | 17 | has_many( 18 | :routes, 19 | type: :route, 20 | serializer: ApiWeb.RouteView 21 | ) 22 | 23 | def routes(%{id: line_id}, _conn) do 24 | State.Route.by_line_id(line_id) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alex Garibay 2 | Chris Freeze 3 | Chris Hayes 4 | Chris McCord 5 | Dave Barker 6 | Gene Shkolnik 7 | Jason Goldberger 8 | John Kohler 9 | Lev Boyarsky 10 | Luke Imhoff 11 | Paul Swartz 12 | Ryan Mahoney 13 | Sebastian Abondano 14 | Stuart Terrett 15 | Thomas Liu 16 | -------------------------------------------------------------------------------- /apps/alb_monitor/lib/alb_monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule ALBMonitor do 2 | @moduledoc """ 3 | When the app is running on AWS, monitors the Application Load Balancer for the instance and 4 | proactively shuts down the app when it begins draining connections, to ensure long-lived event 5 | stream connections are cleanly closed. 6 | """ 7 | 8 | use Application 9 | 10 | def start(_type, _args) do 11 | children = [ 12 | ALBMonitor.Monitor 13 | ] 14 | 15 | opts = [strategy: :one_for_one, name: ALBMonitor.Supervisor] 16 | Supervisor.start_link(children, opts) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/facility/property.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Facility.Property do 2 | @moduledoc """ 3 | 4 | Parser for facility_properties.txt 5 | 6 | """ 7 | use Parse.Simple 8 | 9 | alias Model.Facility.Property 10 | 11 | def parse_row(row) do 12 | %Property{ 13 | name: copy(row["property_id"]), 14 | facility_id: copy(row["facility_id"]), 15 | value: decode_value(row["value"]) 16 | } 17 | end 18 | 19 | defp decode_value(value) do 20 | case Integer.parse(value) do 21 | {integer, ""} -> integer 22 | _ -> value 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/state/test/state/stop/worker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.Stop.WorkerTest do 2 | use ExUnit.Case, async: true 3 | alias Model.Stop 4 | 5 | setup do 6 | worker_id = :test 7 | {:ok, _} = State.Stop.Worker.start_link(worker_id) 8 | 9 | {:ok, %{worker_id: worker_id}} 10 | end 11 | 12 | test "it can add a stop and query it", %{worker_id: worker_id} do 13 | stop = %Stop{id: "1", name: "stop", latitude: 1, longitude: -2} 14 | State.Stop.Worker.new_state(worker_id, [stop]) 15 | 16 | assert State.Stop.Worker.around(worker_id, 1.001, -2.002) == ["1"] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/configure_2fa.html.heex: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/key/edit.html.heex: -------------------------------------------------------------------------------- 1 |

Edit Key

2 |
3 |
Key Details
4 |
5 |
    6 |
  • User E-mail: {@user.email}
  • 7 |
  • Key: {@key.key}
  • 8 |
9 |
10 |
11 | 12 | {render("form.html", 13 | changeset: @changeset, 14 | action: key_path(@conn, :update, @key), 15 | method: :put, 16 | api_versions: @api_versions 17 | )} 18 | 19 | {link("Back", to: portal_path(@conn, :index))} 20 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/controllers/status_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.StatusController do 2 | use ApiWeb.Web, :controller 3 | 4 | def index(conn, _params) do 5 | {feed_version, feed_start_date, feed_end_date} = State.Metadata.feed_metadata() 6 | updated_timestamps = State.Metadata.updated_timestamps() 7 | 8 | data = %{ 9 | feed: %{ 10 | version: feed_version, 11 | start_date: feed_start_date, 12 | end_date: feed_end_date 13 | }, 14 | timestamps: updated_timestamps 15 | } 16 | 17 | render(conn, "index.json-api", data: data) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/controllers/portal/portal_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ClientPortal.PortalController do 2 | @moduledoc false 3 | use ApiWeb.Web, :controller 4 | 5 | def landing(conn, _params) do 6 | conn 7 | |> assign(:pre_container_template, "_hero.html") 8 | |> render("landing.html") 9 | end 10 | 11 | def index(conn, _params) do 12 | keys = ApiAccounts.list_keys_for_user(conn.assigns.user) 13 | 14 | render( 15 | conn, 16 | "index.html", 17 | keys: keys, 18 | api_versions: Application.get_env(:api_web, :versions)[:versions] 19 | ) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/model/lib/model/agency.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.Agency do 2 | @moduledoc """ 3 | Agency represents a branded agency operating transit services. 4 | """ 5 | 6 | use Recordable, [ 7 | :id, 8 | :agency_name 9 | ] 10 | 11 | @type id :: String.t() 12 | 13 | @typedoc """ 14 | * `:id` - Unique ID 15 | * `:agency_name` - Full name of the agency. See 16 | [GTFS `agency.txt` `agency_name`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#agencytxt) 17 | """ 18 | @type t :: %__MODULE__{ 19 | id: id, 20 | agency_name: String.t() 21 | } 22 | end 23 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/redirect_already_authenticated.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RedirectAlreadyAuthenticated do 2 | @moduledoc """ 3 | Redirects to Client Portal index page when user is already authenticated. 4 | """ 5 | import Plug.Conn 6 | import Phoenix.Controller, only: [redirect: 2] 7 | 8 | def init(opts), do: opts 9 | 10 | def call(conn, _) do 11 | case conn.assigns[:user] do 12 | %ApiAccounts.User{} -> 13 | conn 14 | |> redirect(to: ApiWeb.Router.Helpers.portal_path(conn, :index)) 15 | |> halt() 16 | 17 | _ -> 18 | conn 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/accounts/key/edit.html.heex: -------------------------------------------------------------------------------- 1 |

Edit Key

2 |
3 |
Key Details
4 |
5 |
    6 |
  • User E-mail: {@user.email}
  • 7 |
  • Key: {@key.key}
  • 8 |
9 |
10 |
11 | 12 | {render("form.html", 13 | changeset: @changeset, 14 | action: admin_key_path(@conn, :update, @user, @key), 15 | method: :put, 16 | api_versions: @api_versions 17 | )} 18 | 19 | {link("Back", to: admin_user_path(@conn, :show, @user))} 20 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/occupancy_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.OccupancyView do 2 | use ApiWeb.Web, :api_view 3 | 4 | attributes([:id, :status, :percentage]) 5 | 6 | @spec id(Model.CommuterRailOccupancy.t()) :: String.t() 7 | def id(%{trip_name: trip_name}, _conn) do 8 | "occupancy-" <> trip_name 9 | end 10 | 11 | @spec status(Model.CommuterRailOccupancy.t()) :: String.t() 12 | def status(%{status: :many_seats_available}), do: "MANY_SEATS_AVAILABLE" 13 | def status(%{status: :few_seats_available}), do: "FEW_SEATS_AVAILABLE" 14 | def status(%{status: :full}), do: "FULL" 15 | def status(_), do: "" 16 | end 17 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/service_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ServiceView do 2 | use ApiWeb.Web, :api_view 3 | 4 | location(:service_location) 5 | 6 | def service_location(service, conn), do: service_path(conn, :show, service.id) 7 | 8 | attributes([ 9 | :start_date, 10 | :end_date, 11 | :valid_days, 12 | :description, 13 | :schedule_name, 14 | :schedule_type, 15 | :schedule_typicality, 16 | :rating_start_date, 17 | :rating_end_date, 18 | :rating_description, 19 | :added_dates, 20 | :added_dates_notes, 21 | :removed_dates, 22 | :removed_dates_notes 23 | ]) 24 | end 25 | -------------------------------------------------------------------------------- /apps/fetch/lib/fetch/app.ex: -------------------------------------------------------------------------------- 1 | defmodule Fetch.App do 2 | @moduledoc """ 3 | 4 | Application for the various fetching servers. If configured with a 5 | `test: true` key, then does not start the fetchers. 6 | 7 | """ 8 | use Application 9 | 10 | def start(_type, _args) do 11 | opts = Application.fetch_env!(:fetch, Fetch) 12 | 13 | children = [ 14 | {Registry, keys: :unique, name: Fetch.Registry}, 15 | :hackney_pool.child_spec(:fetch_pool, []), 16 | {Fetch, opts} 17 | ] 18 | 19 | opts = [strategy: :one_for_one, name: Fetch.App] 20 | Supervisor.start_link(children, opts) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/controller_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ControllerHelpersTest do 2 | @moduledoc false 3 | use ApiWeb.ConnCase, async: true 4 | import ApiWeb.ControllerHelpers 5 | 6 | describe "conn_service_date/1" do 7 | test "returns a service date and a conn", %{conn: conn} do 8 | assert {%Plug.Conn{}, %Date{}} = conn_service_date(conn) 9 | end 10 | 11 | test "caches the initial date", %{conn: conn} do 12 | initial_conn = conn 13 | {conn, date} = conn_service_date(conn) 14 | assert conn != initial_conn 15 | assert {conn, date} == conn_service_date(conn) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/model/lib/model/wgs84.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.WGS84 do 2 | @moduledoc """ 3 | A [WGS-84](https://en.wikipedia.org/wiki/World_Geodetic_System#A_new_World_Geodetic_System:_WGS.C2.A084) latitude and 4 | longitude 5 | """ 6 | 7 | @typedoc """ 8 | Degrees East, in the [WGS-84](https://en.wikipedia.org/wiki/World_Geodetic_System#A_new_World_Geodetic_System:_WGS.C2.A084) 9 | coordinate system. 10 | """ 11 | @type latitude :: float 12 | 13 | @typedoc """ 14 | Degrees East, in the [WGS-84](https://en.wikipedia.org/wiki/World_Geodetic_System#Longitudes_on_WGS.C2.A084) 15 | coordinate system. 16 | """ 17 | @type longitude :: float 18 | end 19 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/rate_limiter/memcache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.RateLimiter.MemcacheTest do 2 | use ExUnit.Case 3 | import ApiWeb.RateLimiter.Memcache 4 | 5 | @moduletag :memcache 6 | 7 | describe "rate_limited?/2" do 8 | test "correctly determines whether the user is rate limited, and returns the correct number of requests" do 9 | {:ok, _} = start_link(clear_interval: 1000) 10 | user_id = "#{System.monotonic_time()}" 11 | 12 | assert {:remaining, 1} = rate_limited?(user_id, 2) 13 | assert {:remaining, 0} = rate_limited?(user_id, 2) 14 | assert :rate_limited == rate_limited?(user_id, 2) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Sets and enables heart (recommended only in daemon mode) 4 | # case $RELEASE_COMMAND in 5 | # daemon*) 6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" 7 | # export HEART_COMMAND 8 | # export ELIXIR_ERL_OPTIONS="-heart" 9 | # ;; 10 | # *) 11 | # ;; 12 | # esac 13 | 14 | # Set the release to work across nodes. If using the long name format like 15 | # the one below (my_app@127.0.0.1), you need to also uncomment the 16 | # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". 17 | 18 | DIRNAME=$(dirname $0) 19 | 20 | export RELEASE_DISTRIBUTION=sname 21 | export RELEASE_NODE=api 22 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Helpers do 2 | @moduledoc "Helper functions for parsing" 3 | 4 | @doc "Copies a binary, otherwise returns the term unchanged" 5 | @spec copy(term) :: term 6 | def copy(binary) when is_binary(binary) do 7 | :binary.copy(binary) 8 | end 9 | 10 | def copy(other), do: other 11 | 12 | @doc "Copies a binary, but treats the empty string as a nil value" 13 | @spec optional_copy(term) :: term 14 | def optional_copy("") do 15 | # empty string is a default value and should be treated as a not-provided 16 | # value 17 | nil 18 | end 19 | 20 | def optional_copy(value) do 21 | copy(value) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /apps/state_mediator/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :state_mediator, State.FakeModuleA, source: {:system, "FAKE_VAR_A", "default_a"} 4 | 5 | config :state_mediator, State.FakeModuleB, source: {:system, "FAKE_VAR_B", "default_b"} 6 | 7 | config :state_mediator, State.FakeModuleC, source: "default_c" 8 | 9 | config :state_mediator, :commuter_rail_crowding, 10 | enabled: "true", 11 | s3_bucket: "mbta-gtfs-commuter-rail-prod", 12 | s3_object: "crowding-trends.json", 13 | source: "s3" 14 | 15 | # Record the original working directory so that when it changes during the 16 | # test run, we can still find the MBTA_GTFS_FILE. 17 | config :state_mediator, :cwd, File.cwd!() 18 | -------------------------------------------------------------------------------- /semaphore/build_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Required configuration: 5 | # * APP 6 | # * DOCKER_REPO 7 | 8 | # log into docker hub if credentials are in the environment 9 | if [ -n "$DOCKER_USERNAME" ]; then 10 | echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin 11 | fi 12 | 13 | # build docker image and tag it with git hash and aws environment 14 | githash=$(git rev-parse --short HEAD) 15 | docker build --pull -t $APP:latest . 16 | docker tag $APP:latest $DOCKER_REPO:git-$githash 17 | docker tag $APP:latest $DOCKER_REPO:latest 18 | 19 | # push images to ECS image repo 20 | docker push $DOCKER_REPO:git-$githash 21 | docker push $DOCKER_REPO:latest 22 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/multi_route_trips.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.MultiRouteTrips do 2 | @moduledoc """ 3 | Parses `multi_route_trips.txt` CSV from GTFS zip 4 | 5 | added_route_id,trip_id 6 | 12,hybrid 7 | 34,hybrid 8 | CR-Lowell,"gene's" 9 | 10 | """ 11 | 12 | use Parse.Simple 13 | alias Model.MultiRouteTrip 14 | 15 | @doc """ 16 | Parses (non-header) row of `multi_route_trips.txt` 17 | """ 18 | @spec parse_row(row :: %{optional(String.t()) => String.t()}) :: MultiRouteTrip.t() 19 | def parse_row(row) do 20 | %MultiRouteTrip{ 21 | added_route_id: copy(row["added_route_id"]), 22 | trip_id: copy(row["trip_id"]) 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/timezone.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Timezone do 2 | @moduledoc """ 3 | Maintains the local timezone name, offset, and abbreviation. 4 | 5 | The parsers use this to convert a UNIX timestamp into the appropriate local DateTime in an efficient manner. 6 | """ 7 | @doc """ 8 | Given a unix timestamp, converts it to the appropriate local timezone. 9 | 10 | iex> unix_to_local(1522509910) 11 | #DateTime<2018-03-31 11:25:10-04:00 EDT America/New_York> 12 | """ 13 | def unix_to_local(unix_timestamp) when is_integer(unix_timestamp) do 14 | {:ok, datetime} = FastLocalDatetime.unix_to_datetime(unix_timestamp, "America/New_York") 15 | datetime 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/api_web/test/support/fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Fixtures do 2 | @moduledoc false 3 | 4 | @test_password "password" 5 | @valid_user_attrs %{ 6 | email: "authorized@example.com", 7 | password: @test_password 8 | } 9 | 10 | def fixture(:totp_user) do 11 | time = DateTime.utc_now() |> DateTime.add(-35, :second) 12 | {:ok, user} = ApiAccounts.create_user(@valid_user_attrs) 13 | {:ok, user} = ApiAccounts.generate_totp_secret(user) 14 | 15 | {:ok, user} = 16 | ApiAccounts.enable_totp( 17 | user, 18 | NimbleTOTP.verification_code(user.totp_secret_bin, time: time), 19 | time: time 20 | ) 21 | 22 | user 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/api_web/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | excludes = [:integration] 2 | 3 | # try to connect to memcache: if it fails, don't run those tests 4 | excludes = 5 | case :gen_tcp.connect(~c"localhost", 11_211, [:inet], 100) do 6 | {:ok, sock} -> 7 | :gen_tcp.close(sock) 8 | excludes 9 | 10 | {:error, _} -> 11 | [:memcache | excludes] 12 | end 13 | 14 | ExUnit.start(exclude: excludes) 15 | 16 | defmodule ApiWeb.Test.ProcessHelper do 17 | use ExUnit.Case 18 | 19 | def assert_stopped(pid) do 20 | if Process.alive?(pid) do 21 | ref = Process.monitor(pid) 22 | assert_receive {:DOWN, ^ref, :process, ^pid, _} 23 | else 24 | :ok 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/model/lib/model/facility/property.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.Facility.Property do 2 | @moduledoc """ 3 | A property of a facility. The names are not unique, even within a facility_id. 4 | """ 5 | 6 | use Recordable, [ 7 | :facility_id, 8 | :name, 9 | :value, 10 | :updated_at 11 | ] 12 | 13 | @typedoc """ 14 | * `:name` - Name of the property 15 | * `:facility_id` - The `Model.Facility.id` this property applies to. 16 | * `:value` - Value of the property 17 | """ 18 | @type t :: %__MODULE__{ 19 | name: String.t(), 20 | facility_id: Model.Facility.id(), 21 | value: term, 22 | updated_at: DateTime.t() | nil 23 | } 24 | end 25 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/clear_metadata_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.ClearMetadataTest do 2 | @moduledoc false 3 | use ApiWeb.ConnCase 4 | alias ApiWeb.Plugs.ClearMetadata 5 | 6 | @opts ClearMetadata.init(~w(metadata_value)a) 7 | 8 | test "clears given metadata values", %{conn: conn} do 9 | Logger.metadata(metadata_value: :value) 10 | assert conn == ClearMetadata.call(conn, @opts) 11 | assert Logger.metadata() == [] 12 | end 13 | 14 | test "does not clear metadata values not given", %{conn: conn} do 15 | Logger.metadata(metadata_keep: :value) 16 | assert conn == ClearMetadata.call(conn, @opts) 17 | assert Logger.metadata() == [metadata_keep: :value] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/health/lib/health/checker.ex: -------------------------------------------------------------------------------- 1 | defmodule Health.Checker do 2 | @moduledoc """ 3 | Aggregator for multiple health checks. 4 | 5 | We can return both data about the checks (current/0) as well as a boolean 6 | as to whether we're healthy or not (healthy?/0). 7 | """ 8 | @checkers Application.compile_env(:health, :checkers) 9 | 10 | def current do 11 | :current 12 | |> each_checker 13 | |> Enum.reduce([], &Keyword.merge/2) 14 | end 15 | 16 | def healthy? do 17 | :healthy? 18 | |> each_checker 19 | |> Enum.all?() 20 | end 21 | 22 | defp each_checker(fun_name) do 23 | for checker <- @checkers do 24 | apply(checker, fun_name, []) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/agency.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Agency do 2 | @moduledoc """ 3 | Parses `agency.txt` CSV from GTFS zip 4 | 5 | agency_id,agency_name,agency_url,agency_timezone,agency_lang,agency_phone 6 | 1,MBTA,http://www.mbta.com,America/New_York,EN,617-222-3200 7 | """ 8 | 9 | use Parse.Simple 10 | alias Model.Agency 11 | 12 | @doc """ 13 | Parses (non-header) row of `agency.txt` 14 | 15 | ## Columns 16 | 17 | * `"agency_id"` - `Model.Agency.t` - `id` 18 | * `"agency_name"` - `Model.Agency.t` - `agency_name` 19 | """ 20 | def parse_row(row) do 21 | %Agency{ 22 | id: copy(row["agency_id"]), 23 | agency_name: copy(row["agency_name"]) 24 | } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/calendar_dates.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.CalendarDates do 2 | @moduledoc """ 3 | Parser for GTFS calendar_dates.txt 4 | """ 5 | @behaviour Parse 6 | defstruct [:service_id, :date, :added, :holiday_name] 7 | 8 | def parse(blob) do 9 | blob 10 | |> BinaryLineSplit.stream!() 11 | |> SimpleCSV.decode() 12 | |> Enum.map(&parse_row/1) 13 | end 14 | 15 | defp parse_row(row) do 16 | %__MODULE__{ 17 | service_id: :binary.copy(row["service_id"]), 18 | date: row["date"] |> Timex.parse!("{YYYY}{0M}{0D}") |> NaiveDateTime.to_date(), 19 | added: row["exception_type"] == "1", 20 | holiday_name: :binary.copy(row["holiday_name"]) 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # App artifacts 3 | /_build 4 | /tmp 5 | /data 6 | /db 7 | /deps 8 | /*.ez 9 | /cache 10 | /Mnesia.*/ 11 | # Generate on crash by the VM 12 | erl_crash.dump 13 | /rel/api 14 | *.secret.exs 15 | 16 | apps/api/priv/static/swagger.json 17 | 18 | # Elastic Beanstalk Files 19 | aws/ 20 | .elasticbeanstalk/* 21 | !.elasticbeanstalk/*.cfg.yml 22 | !.elasticbeanstalk/*.global.yml 23 | /api-build.zip 24 | apps/state/bench/snapshots 25 | cover 26 | 27 | # ex_doc 28 | /doc 29 | /apps/api_web/priv/static/swagger.json 30 | 31 | .vscode/ 32 | .elixir_ls/ 33 | 34 | # Python artifacts (for locust) 35 | __pycache__ 36 | 37 | # local db 38 | shared-local-instance.db 39 | /bin 40 | dynamodb-local-metadata.json 41 | -------------------------------------------------------------------------------- /apps/parse/test/parse/agency_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.AgencyTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Parse.Agency 5 | alias Model.Agency 6 | 7 | describe "parse_row/1" do 8 | test "parses a route CSV map into an %Agency{}" do 9 | row = %{ 10 | "agency_id" => "1", 11 | "agency_name" => "MBTA", 12 | "agency_url" => "http://www.mbta.com", 13 | "agency_timezone" => "America/New_York", 14 | "agency_lang" => "EN", 15 | "agency_phone" => "617-222-3200" 16 | } 17 | 18 | expected = %Agency{ 19 | id: "1", 20 | agency_name: "MBTA" 21 | } 22 | 23 | assert parse_row(row) == expected 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/state_mediator/test/state_mediator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateMediatorTest do 2 | use ExUnit.Case 3 | 4 | describe "source_url/1" do 5 | test "returns default config value when no environment variable set" do 6 | assert StateMediator.source_url(State.FakeModuleA) == "default_a" 7 | end 8 | 9 | test "returns environment value when set set" do 10 | expected = "config_b" 11 | :os.putenv(~c"FAKE_VAR_B", String.to_charlist(expected)) 12 | assert StateMediator.source_url(State.FakeModuleB) == expected 13 | end 14 | 15 | test "returns config value when explicitly set" do 16 | assert StateMediator.source_url(State.FakeModuleC) == "default_c" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/api_accounts/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | *.secret.exs 23 | 24 | # for the DynamoDB local instance used in testing 25 | bin/ 26 | shared-local-instance.db 27 | -------------------------------------------------------------------------------- /apps/health/lib/health.ex: -------------------------------------------------------------------------------- 1 | defmodule Health do 2 | @moduledoc """ 3 | Monitors health of the rest of the OTP applications in the umbrella project 4 | """ 5 | 6 | use Application 7 | 8 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 9 | # for more information on OTP Applications 10 | def start(_type, _args) do 11 | # Define workers and child supervisors to be supervised 12 | children = [ 13 | Health.Checkers.State 14 | ] 15 | 16 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 17 | # for other strategies and supported options 18 | opts = [strategy: :one_for_one, name: Health.Supervisor] 19 | Supervisor.start_link(children, opts) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/api_web/README.md: -------------------------------------------------------------------------------- 1 | # Api 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add `api` to your list of dependencies in `mix.exs`: 10 | 11 | ```elixir 12 | def deps do 13 | [{:api, "~> 0.1.0"}] 14 | end 15 | ``` 16 | 17 | 2. Ensure `api` is started before your application: 18 | 19 | ```elixir 20 | def application do 21 | [applications: [:api]] 22 | end 23 | ``` 24 | 25 | 26 | ## Swagger and Tests 27 | 28 | The Swagger API documentation is used to validate incoming requests to all 29 | requests that use the :api pipeline (see 30 | `apps/api_web/lib/api_web/router.ex`). 31 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/clear_metadata.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.ClearMetadata do 2 | @moduledoc """ 3 | Clear Logger metadata at the start of a new request. 4 | 5 | Bandit is more agressive about re-using processes than Cowboy was, which means we 6 | can't rely on the old behavior of the Logger metadata being automatically cleared 7 | when the process is terminated. 8 | 9 | This takes a list of metadata keys to clear. 10 | """ 11 | @behaviour Plug 12 | 13 | @impl Plug 14 | def init(keys_to_clear) do 15 | for key <- keys_to_clear do 16 | {key, nil} 17 | end 18 | end 19 | 20 | @impl Plug 21 | def call(conn, metadata) do 22 | _ = Logger.metadata(metadata) 23 | 24 | conn 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/show.html.heex: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/require_user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RequireUserTest do 2 | use ApiWeb.ConnCase, async: true 3 | 4 | test "init" do 5 | opts = [] 6 | assert ApiWeb.Plugs.RequireUser.init(opts) == opts 7 | end 8 | 9 | test "proceeds if user present", %{conn: conn} do 10 | conn = 11 | conn 12 | |> assign(:user, %ApiAccounts.User{}) 13 | |> ApiWeb.Plugs.RequireUser.call([]) 14 | 15 | refute conn.status 16 | refute conn.halted 17 | end 18 | 19 | test "redirect to login if user isn't present", %{conn: conn} do 20 | conn = ApiWeb.Plugs.RequireUser.call(conn, []) 21 | assert redirected_to(conn) == session_path(conn, :new) 22 | assert conn.halted 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/accounts/key/index.html.heex: -------------------------------------------------------------------------------- 1 |

Find user by key

2 | {render("search.html", 3 | conn: @conn, 4 | action: admin_key_path(@conn, :find_user_by_key), 5 | method: :post 6 | )} 7 | 8 |

Pending Key Approvals

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | <%= for {key, user} <- @key_requests do %> 18 | 19 | 22 | 23 | 24 | <% end %> 25 | 26 |
User EmailRequested Date
20 | {link(user.email, to: admin_user_path(@conn, :show, user), target: "_blank")} 21 | {key.requested_date}
27 | -------------------------------------------------------------------------------- /apps/model/lib/model/commuter_rail_occupancy.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.CommuterRailOccupancy do 2 | @moduledoc """ 3 | An expected or predicted level of occupancy for a given commuter rail trip. 4 | Stores the data we receive from Keolis, indexed by train name. 5 | Naming inspired by [GTFS-Occupancies proposal](https://github.com/google/transit/pull/240). 6 | """ 7 | 8 | use Recordable, [ 9 | :trip_name, 10 | :status, 11 | :percentage 12 | ] 13 | 14 | @type status :: 15 | :many_seats_available 16 | | :few_seats_available 17 | | :full 18 | 19 | @type t :: %__MODULE__{ 20 | trip_name: String.t(), 21 | status: status(), 22 | percentage: non_neg_integer() 23 | } 24 | end 25 | -------------------------------------------------------------------------------- /.github/workflows/asana.yml: -------------------------------------------------------------------------------- 1 | name: Asana integration for GitHub PRs 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | types: [review_requested, closed, opened, reopened, converted_to_draft, edited, ready_for_review] 6 | pull_request_review: 7 | types: [submitted] 8 | 9 | jobs: 10 | call-workflow: 11 | uses: mbta/workflows/.github/workflows/asana.yml@main 12 | with: 13 | development-section: "In Development" 14 | review-section: "Pending Review" 15 | merged-section: "Merged / Not Deployed" 16 | trigger-phrase: "\\*\\*Asana Ticket:\\*\\*" 17 | attach-pr: true 18 | secrets: 19 | asana-token: ${{ secrets.ASANA_PERSONAL_ACCESS_TOKEN }} 20 | github-secret: ${{ secrets.ASANA_GITHUB_INTEGRATION_SECRET }} 21 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/view_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ViewHelpers do 2 | @moduledoc """ 3 | Shared functions for HTML views. 4 | """ 5 | use Phoenix.Component 6 | 7 | @doc """ 8 | Generates a form-group div for a field. 9 | 10 | If the field has an error, the appropriate error class is added to the 11 | form group div. 12 | """ 13 | def form_group(%{form: form, field: field} = assigns) do 14 | if Map.get(form.source.errors, field, []) == [] do 15 | ~H""" 16 |
17 | {render_slot(@inner_block)} 18 |
19 | """ 20 | else 21 | ~H""" 22 |
23 | {render_slot(@inner_block)} 24 |
25 | """ 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/require_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RequireAdmin do 2 | @moduledoc """ 3 | Plug enforcing a user to have the administrator role. 4 | """ 5 | import Plug.Conn 6 | import Phoenix.Controller, only: [render: 3, put_view: 2] 7 | 8 | def init(opts), do: opts 9 | 10 | def call(conn, _opts) do 11 | conn 12 | |> fetch_user() 13 | |> authenticate(conn) 14 | end 15 | 16 | defp fetch_user(conn) do 17 | conn.assigns[:user] 18 | end 19 | 20 | defp authenticate(%ApiAccounts.User{role: "administrator"}, conn), do: conn 21 | 22 | defp authenticate(_, conn) do 23 | conn 24 | |> put_status(:not_found) 25 | |> put_view(ApiWeb.ErrorView) 26 | |> render("404.html", []) 27 | |> halt() 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/parse/test/parse/directions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.DirectionsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Parse.Directions 5 | 6 | describe "parse_row/1" do 7 | test "parses a route CSV map into a valid structure" do 8 | row = %{ 9 | "route_id" => "708", 10 | "direction_id" => "0", 11 | "direction" => "Outbound", 12 | "direction_destination" => "Beth Israel Deaconess or Boston Medical Center" 13 | } 14 | 15 | expected = %Parse.Directions{ 16 | route_id: "708", 17 | direction_id: "0", 18 | direction: "Outbound", 19 | direction_destination: "Beth Israel Deaconess or Boston Medical Center" 20 | } 21 | 22 | assert parse_row(row) == expected 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/api_accounts/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | config :ex_aws, 6 | dynamodb: [ 7 | port: "8000", 8 | scheme: "http://", 9 | host: "localhost" 10 | ], 11 | json_codec: Jason 12 | 13 | config :ex_aws, :hackney_opts, 14 | recv_timeout: 30_000, 15 | pool: :ex_aws_pool 16 | 17 | config :email_checker, 18 | default_dns: {8, 8, 8, 8}, 19 | smtp_retries: 1, 20 | timeout_milliseconds: 6000, 21 | validations: [EmailChecker.Check.Format, EmailChecker.Check.MX] 22 | 23 | config :api_accounts, ApiAccounts.Mailer, adapter: Bamboo.SesAdapter 24 | 25 | config :api_accounts, migrate_on_start: false 26 | 27 | import_config "#{config_env()}.exs" 28 | -------------------------------------------------------------------------------- /apps/fetch/test/fetch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FetchTest do 2 | use ExUnit.Case, async: true 3 | import Plug.Conn 4 | 5 | setup do 6 | lasso = Lasso.open() 7 | url = "http://localhost:#{lasso.port}" 8 | {:ok, %{lasso: lasso, url: url}} 9 | end 10 | 11 | test "can fetch a URL", %{lasso: lasso, url: url} do 12 | Lasso.expect(lasso, "GET", "/", fn conn -> 13 | etag = "etag" 14 | 15 | case get_req_header(conn, "if-none-match") do 16 | [^etag] -> 17 | resp(conn, 304, "") 18 | 19 | [] -> 20 | conn 21 | |> put_resp_header("ETag", etag) 22 | |> resp(200, "body") 23 | end 24 | end) 25 | 26 | assert Fetch.fetch_url(url) == {:ok, "body"} 27 | assert Fetch.fetch_url(url) == :unmodified 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/health/lib/health/checkers/run_queue.ex: -------------------------------------------------------------------------------- 1 | defmodule Health.Checkers.RunQueue do 2 | @moduledoc """ 3 | Health check for monitoring the Erlang [Run 4 | Queue](http://erlang.org/doc/man/erlang.html#statistics-1). 5 | 6 | This check always returns healthy as we don't want to kill tasks based on the run queue length. 7 | Instead it logs the maximum run queue length across all schedulers for monitoring purposes. 8 | """ 9 | require Logger 10 | 11 | def current do 12 | [run_queue: max_queue_length()] 13 | end 14 | 15 | def healthy? do 16 | max_length = max_queue_length() 17 | _ = Logger.info("run_queue_check max_run_queue_length=#{max_length}") 18 | true 19 | end 20 | 21 | defp max_queue_length do 22 | Enum.max(:erlang.statistics(:run_queue_lengths)) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/health/test/health/checkers/ports_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Health.Checkers.PortsTest do 2 | use ExUnit.Case 3 | alias Health.Checkers.Ports 4 | 5 | describe "current/0" do 6 | test "returns an integer number of ports" do 7 | kw = Ports.current() 8 | assert kw[:ports] >= 0 9 | end 10 | end 11 | 12 | describe "healthy?" do 13 | test "true if the number of ports is low" do 14 | assert Ports.healthy?() 15 | end 16 | 17 | test "false if the number of ports is higher than the configuration" do 18 | old = Application.get_env(:health, Ports) 19 | 20 | on_exit(fn -> 21 | Application.put_env(:health, Ports, old) 22 | end) 23 | 24 | Application.put_env(:health, Ports, max_ports: -1) 25 | 26 | refute Ports.healthy?() 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/experimental_features.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.ExperimentalFeatures do 2 | @moduledoc """ 3 | Allows a requestor to opt into experimental features in the API. 4 | 5 | By including the `x-enable-experimental-features: true` header, a user 6 | can opt into data and features that might change without prior warning 7 | or without a backwards-compatible fallback. 8 | 9 | This places a `:experimental_features_enabled?` in the conn's assigns. 10 | """ 11 | @behaviour Plug 12 | import Plug.Conn 13 | 14 | @impl Plug 15 | def init(options) do 16 | options 17 | end 18 | 19 | @impl Plug 20 | def call(conn, _) do 21 | enabled? = get_req_header(conn, "x-enable-experimental-features") == ["true"] 22 | assign(conn, :experimental_features_enabled?, enabled?) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/experimental_features_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.ExperimentalFeaturesTest do 2 | import Phoenix.ConnTest 3 | use ApiWeb.ConnCase 4 | 5 | test "init" do 6 | assert ApiWeb.Plugs.ExperimentalFeatures.init([]) == [] 7 | end 8 | 9 | describe ":experimental_features_enabled?" do 10 | test "set to true if x-enable-experimental-features header included", %{conn: conn} do 11 | conn = put_req_header(conn, "x-enable-experimental-features", "true") 12 | conn = get(conn, "/stops/") 13 | assert conn.assigns.experimental_features_enabled? 14 | end 15 | 16 | test "set to false if x-enable-experimental-features header absent", %{conn: conn} do 17 | conn = get(conn, "/stops/") 18 | refute conn.assigns.experimental_features_enabled? 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # By default, the umbrella project as well as each child 6 | # application will require this configuration file, ensuring 7 | # they all use the same configuration. While one could 8 | # configure all applications here, we prefer to delegate 9 | # back to each application for organization purposes. 10 | for config <- "../apps/*/config/config.exs" |> Path.expand(__DIR__) |> Path.wildcard() do 11 | import_config config 12 | end 13 | 14 | # Sample configuration (overrides the imported configuration above): 15 | # 16 | # config :logger, :console, 17 | # level: :info, 18 | # format: "$date $time [$level] $metadata$message\n", 19 | # metadata: [:user_id] 20 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/require_2factor.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.Require2Factor do 2 | @moduledoc """ 3 | Plug enforcing a user to have 2fa enabled 4 | """ 5 | 6 | import Phoenix.Controller 7 | 8 | def init(opts), do: opts 9 | 10 | def call(conn, _opts) do 11 | conn 12 | |> fetch_user() 13 | |> authenticate(conn) 14 | end 15 | 16 | defp fetch_user(conn) do 17 | conn.assigns[:user] 18 | end 19 | 20 | defp authenticate(%ApiAccounts.User{totp_enabled: true}, conn), do: conn 21 | 22 | defp authenticate(_, conn) do 23 | conn 24 | |> put_flash( 25 | :error, 26 | "Account does not have 2-Factor Authentication enabled. Please enable before performing administrative tasks." 27 | ) 28 | |> redirect(to: ApiWeb.Router.Helpers.user_path(conn, :configure_2fa)) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/parse/test/parse/feed_info_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.FeedInfoTest do 2 | use ExUnit.Case, async: true 3 | import Parse.FeedInfo 4 | alias Model.Feed 5 | 6 | @blob """ 7 | "feed_publisher_name","feed_publisher_url","feed_lang","feed_start_date","feed_end_date","feed_version" 8 | "MBTA","http://www.mbta.com","EN",20170228,20170623,"Spring 2017 version 2D, 3/2/17" 9 | """ 10 | 11 | describe "parse/1" do 12 | test "parses a CSV into a list of %Feed{} structs" do 13 | assert parse(@blob) == 14 | [ 15 | %Feed{ 16 | name: "MBTA", 17 | start_date: ~D[2017-02-28], 18 | end_date: ~D[2017-06-23], 19 | version: "Spring 2017 version 2D, 3/2/17" 20 | } 21 | ] 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | use PhoenixHTML4Compat 6 | 7 | @doc """ 8 | Translates an error message using gettext. 9 | """ 10 | def translate_error({msg, _opts}) do 11 | msg 12 | end 13 | 14 | @doc """ 15 | Generates tag for inlined form input errors. 16 | """ 17 | def error_tag(form, field, humanized \\ nil) do 18 | Enum.map(Map.get(form.source.errors, field, []), fn error -> 19 | humanized_field = humanized || Phoenix.Naming.humanize(field) 20 | translated_error = translate_error({error, []}) 21 | error_message = "#{humanized_field} #{translated_error}." 22 | content_tag(:span, error_message, class: "help-block") 23 | end) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/events/lib/events/gather.ex: -------------------------------------------------------------------------------- 1 | defmodule Events.Gather do 2 | @moduledoc """ 3 | Gathers multiple event `keys` so that only when all `keys` are `received` is `callback` called, so that event 4 | callbacks don't need to handle subsets of events. 5 | """ 6 | 7 | defstruct [:keys, :callback, received: %{}] 8 | 9 | def new(keys, callback) when is_list(keys) and is_function(callback, 1) do 10 | %__MODULE__{keys: MapSet.new(keys), callback: callback} 11 | end 12 | 13 | def update(%__MODULE__{keys: keys, received: received, callback: callback} = state, key, value) do 14 | received = Map.put(received, key, value) 15 | 16 | if received |> Map.keys() |> MapSet.new() == keys do 17 | callback.(received) 18 | %{state | received: %{}} 19 | else 20 | %{state | received: received} 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/controllers/health_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.HealthController do 2 | use ApiWeb.Web, :controller 3 | 4 | require Logger 5 | 6 | def index(conn, _params) do 7 | health = Health.Checker.current() 8 | 9 | status = 10 | if Health.Checker.healthy?() do 11 | :ok 12 | else 13 | :service_unavailable 14 | end 15 | 16 | body = 17 | health 18 | |> Enum.into(%{}) 19 | |> Jason.encode!() 20 | 21 | _ = log_health_check(health, status) 22 | 23 | conn 24 | |> put_resp_content_type("application/json") 25 | |> send_resp(status, body) 26 | end 27 | 28 | defp log_health_check(health, :service_unavailable) do 29 | Logger.info("health_check healthy=false #{inspect(health)}") 30 | end 31 | 32 | defp log_health_check(_, _), do: :ignored 33 | end 34 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/deadline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.DeadlineTest do 2 | @moduledoc false 3 | use ApiWeb.ConnCase, async: true 4 | import ApiWeb.Plugs.Deadline 5 | 6 | setup %{conn: conn} do 7 | conn = call(conn, init([])) 8 | {:ok, %{conn: conn}} 9 | end 10 | 11 | describe "check!/1" do 12 | test "returns :ok if the deadline is met", %{conn: conn} do 13 | assert :ok = 14 | conn 15 | |> set(5_000) 16 | |> check! 17 | end 18 | 19 | test "returns :ok if no deadline was set", %{conn: conn} do 20 | assert :ok = check!(conn) 21 | end 22 | 23 | test "raises an exception if the deadline is missed", %{conn: conn} do 24 | conn = set(conn, -1) 25 | 26 | assert_raise ApiWeb.Plugs.Deadline.Error, fn -> 27 | check!(conn) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/time.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Time do 2 | @moduledoc """ 3 | Helpers for times and dates 4 | """ 5 | 6 | @spec now() :: DateTime.t() 7 | def now do 8 | now_unix = System.system_time(:second) 9 | {:ok, dt} = FastLocalDatetime.unix_to_datetime(now_unix, "America/New_York") 10 | dt 11 | end 12 | 13 | @spec service_date() :: Date.t() 14 | @spec service_date(DateTime.t()) :: Date.t() 15 | def service_date(current_time \\ DateTime.utc_now()) 16 | 17 | def service_date(%{year: _} = current_time) do 18 | current_unix = DateTime.to_unix(current_time) 19 | back_three_hours = current_unix - 10_800 20 | {:ok, dt} = FastLocalDatetime.unix_to_datetime(back_three_hours, "America/New_York") 21 | DateTime.to_date(dt) 22 | end 23 | 24 | def service_date(%Timex.AmbiguousDateTime{before: before}) do 25 | service_date(before) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/state/test/state/line_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.LineTest do 2 | use ExUnit.Case 3 | alias Model.Line 4 | 5 | setup do 6 | State.Line.new_state([]) 7 | :ok 8 | end 9 | 10 | test "returns nil for unknown line" do 11 | assert State.Line.by_id("1") == nil 12 | end 13 | 14 | test "it can add a line and query it" do 15 | line = %Line{ 16 | id: "1", 17 | short_name: "1st Line", 18 | long_name: "First Line", 19 | color: "00843D", 20 | text_color: "FFFFFF", 21 | sort_order: 1 22 | } 23 | 24 | State.Line.new_state([line]) 25 | 26 | assert State.Line.by_id("1") == %Line{ 27 | id: "1", 28 | short_name: "1st Line", 29 | long_name: "First Line", 30 | color: "00843D", 31 | text_color: "FFFFFF", 32 | sort_order: 1 33 | } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /rel/.ebextensions/02nginx.config: -------------------------------------------------------------------------------- 1 | files: 2 | "/etc/nginx/nginx.conf": 3 | mode: "00644" 4 | owner: "root" 5 | group: "root" 6 | content: | 7 | # Elastic Beanstalk Nginx Configuration File 8 | user nginx; 9 | worker_processes auto; 10 | error_log /var/log/nginx/error.log; 11 | pid /var/run/nginx.pid; 12 | events { 13 | worker_connections 200000; 14 | use epoll; 15 | multi_accept on; 16 | } 17 | http { 18 | include /etc/nginx/mime.types; 19 | default_type application/octet-stream; 20 | access_log /var/log/nginx/access.log; 21 | log_format healthd '$msec"$uri"$status"$request_time"$upstream_response_time"$http_x_forwarded_for'; 22 | include /etc/nginx/conf.d/*.conf; 23 | include /etc/nginx/sites-enabled/*; 24 | } 25 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/sentry_event_filter.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.SentryEventFilter do 2 | @moduledoc """ 3 | Provides a filter for exceptions coming from 404 errors 4 | """ 5 | 6 | # Sentry allows this callback to both modify events before they get sent, 7 | # and filter events to prevent them from being sent at all. 8 | # We only do the latter. Returning false prevents sending. 9 | @spec filter_event(Sentry.Event.t()) :: Sentry.Event.t() | false 10 | def filter_event(%Sentry.Event{ 11 | source: :plug, 12 | original_exception: %Phoenix.Router.NoRouteError{} 13 | }) do 14 | false 15 | end 16 | 17 | def filter_event(%Sentry.Event{message: %Sentry.Interfaces.Message{}} = event) do 18 | if String.contains?(event.message.formatted, "{{{%Phoenix.Router.NoRouteError"), 19 | do: false, 20 | else: event 21 | end 22 | 23 | def filter_event(event), do: event 24 | end 25 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/route_pattern_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.RoutePatternView do 2 | use ApiWeb.Web, :api_view 3 | 4 | location(:route_pattern_location) 5 | 6 | def route_pattern_location(route_pattern, conn), 7 | do: route_pattern_path(conn, :show, route_pattern.id) 8 | 9 | has_one( 10 | :route, 11 | type: :route, 12 | serializer: ApiWeb.RouteView 13 | ) 14 | 15 | has_one( 16 | :representative_trip, 17 | type: :trip, 18 | serializer: ApiWeb.TripView, 19 | field: :representative_trip_id 20 | ) 21 | 22 | # no cover 23 | attributes([ 24 | :direction_id, 25 | :name, 26 | :time_desc, 27 | :typicality, 28 | :sort_order, 29 | :canonical 30 | ]) 31 | 32 | def representative_trip(%{representative_trip_id: trip_id}, conn) do 33 | optional_relationship("representative_trip", trip_id, &State.Trip.by_primary_id/1, conn) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/_forgot_password.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Forgot Password

4 | 5 |
6 | <%= form_for @changeset, user_path(@conn, :forgot_password_submit), [], fn f -> %> 7 | <%= unless @changeset.valid? do %> 8 |
9 |

Oops, something went wrong! Please check the errors below.

10 |
11 | <% end %> 12 | 13 | <.form_group form={f} field={:email}> 14 | {label(f, :email, class: "control-label")} 15 | {email_input(f, :email, class: "form-control")} 16 | {error_tag(f, :email)} 17 | 18 | 19 |
20 | {submit("Reset Password", class: "btn btn-primary")} 21 |
22 | <% end %> 23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /apps/state/test/state/alert/hooks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.Alert.HooksTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | import State.Alert.Hooks 5 | 6 | @alert %Model.Alert{ 7 | id: "alert1", 8 | informed_entity: [ 9 | %{ 10 | route_type: 3, 11 | route: "1", 12 | direction_id: 0, 13 | stop: "place-cool", 14 | activities: ["BOARD", "RIDE"] 15 | }, 16 | %{ 17 | route_type: 3, 18 | route: "1", 19 | direction_id: 0, 20 | stop: "place-cool", 21 | activities: ["EXIT"] 22 | } 23 | ], 24 | severity: 1 25 | } 26 | 27 | test "pre_insert_hook/1 merges informed entities" do 28 | [alert] = pre_insert_hook(@alert) 29 | ie = Map.get(alert, :informed_entity) 30 | assert is_list(ie) 31 | assert length(ie) == 1 32 | assert length(ie |> hd |> Map.get(:activities)) == 3 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/rate_limiter/limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.RateLimiter.Limiter do 2 | @moduledoc """ 3 | Behavior for backends to the V3 API rate limiter. 4 | 5 | - `start_link(opts)` is called to start the backend by the supervisor. 6 | - `rate_limited?(user_id, max_requests)` returns :rate_limited if the user_id has used too many 7 | requests, or else {:remaining, N} where N is the number of requests remaining for the user_id 8 | in this time period. 9 | 10 | The main option passed to `start_link/1` is `clear_interval` which is a 11 | number of milliseconds to bucket the requests into. 12 | 13 | """ 14 | @callback start_link(Keyword.t()) :: {:ok, pid} 15 | @callback rate_limited?(String.t(), non_neg_integer) :: 16 | {:remaining, non_neg_integer} | :rate_limited 17 | @callback clear() :: :ok 18 | @callback list() :: [String.t()] 19 | 20 | @optional_callbacks [clear: 0, list: 0] 21 | end 22 | -------------------------------------------------------------------------------- /apps/model/lib/model/route_pattern.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.RoutePattern do 2 | @moduledoc """ 3 | A variant of service run within a single route_id 4 | [GTFS `route_patterns.txt`](https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs.md#route_patternstxt) 5 | """ 6 | 7 | use Recordable, [ 8 | :id, 9 | :route_id, 10 | :direction_id, 11 | :name, 12 | :time_desc, 13 | :typicality, 14 | :sort_order, 15 | :representative_trip_id, 16 | :canonical 17 | ] 18 | 19 | @type id :: String.t() 20 | @type t :: %__MODULE__{ 21 | id: id, 22 | route_id: Model.Route.id(), 23 | direction_id: Model.Direction.id(), 24 | name: String.t(), 25 | time_desc: String.t() | nil, 26 | typicality: 0..5, 27 | sort_order: integer(), 28 | representative_trip_id: Model.Trip.id(), 29 | canonical: boolean() 30 | } 31 | end 32 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWebTest do 2 | @moduledoc false 3 | use ExUnit.Case 4 | doctest ApiWeb 5 | 6 | describe "runtime_config!/0" do 7 | setup do 8 | old_env = Application.get_env(:api_web, :api_pipeline) 9 | 10 | on_exit(fn -> 11 | Application.put_env(:api_web, :api_pipeline, old_env) 12 | end) 13 | 14 | :ok 15 | end 16 | end 17 | 18 | test "config/1 returns configuration" do 19 | assert Keyword.keyword?(ApiWeb.config(ApiWeb.Endpoint)) 20 | end 21 | 22 | test "config/1 raises when key is missing" do 23 | assert_raise ArgumentError, fn -> ApiWeb.config(:not_exists) end 24 | end 25 | 26 | test "config/2 returns configuration" do 27 | assert is_list(ApiWeb.config(ApiWeb.Endpoint, :url)) 28 | end 29 | 30 | test "config/2 raises when key is missing" do 31 | assert_raise RuntimeError, fn -> ApiWeb.config(ApiWeb.Endpoint, :not_exists) end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/state/lib/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Logger do 2 | @moduledoc """ 3 | Helpers for logging about `State` 4 | """ 5 | 6 | require Logger 7 | 8 | # Don't use `:erlang.convert_time_unit/3` directly on the `microseconds` from `:timer.tc/1` because 9 | # `:erlang.convert_time_unit/3` takes floor of conversion, so we'd lose fractional milliseconds. 10 | @microseconds_per_millisecond :erlang.convert_time_unit(1, :millisecond, :microsecond) 11 | 12 | @doc """ 13 | Measures time of `function` and logs 14 | """ 15 | @spec debug_time(measured :: (-> result), message :: (milliseconds :: float -> String.t())) :: 16 | result 17 | when result: any 18 | def debug_time(measured, message) when is_function(measured, 0) and is_function(message, 1) do 19 | {microseconds, result} = :timer.tc(measured) 20 | _ = Logger.debug(fn -> message.(microseconds / @microseconds_per_millisecond) end) 21 | 22 | result 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/events/test/events/gather_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Events.GatherTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | alias Events.Gather 5 | 6 | test "gather calls callback when all keys are present" do 7 | keys = [1, 2] 8 | state = Gather.new(keys, fn %{1 => :one, 2 => :two} -> send(self(), :ok) end) 9 | refute_receive :ok 10 | state = Gather.update(state, 1, :one) 11 | refute_receive :ok 12 | state = Gather.update(state, 2, :two) 13 | assert_receive :ok 14 | 15 | # does not re-call the callback 16 | Gather.update(state, 1, :one) 17 | refute_receive :ok 18 | end 19 | 20 | test "only remembers the last value sent" do 21 | keys = [1, 2] 22 | state = Gather.new(keys, fn %{1 => :one, 2 => :two} -> send(self(), :ok) end) 23 | state = Gather.update(state, 1, :other) 24 | state = Gather.update(state, 1, :one) 25 | Gather.update(state, 2, :two) 26 | assert_receive :ok 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/parse/test/parse/line_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.LineTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Parse.Line 5 | alias Model.Line 6 | 7 | describe "parse_row/1" do 8 | test "parses a route CSV map into a %Line{}" do 9 | row = %{ 10 | "line_id" => "line-Middleborough", 11 | "line_short_name" => "", 12 | "line_long_name" => "Middleborough/Lakeville Line", 13 | "line_desc" => "", 14 | "line_url" => "", 15 | "line_color" => "80276C", 16 | "line_text_color" => "FFFFFF", 17 | "line_sort_order" => "59" 18 | } 19 | 20 | expected = %Line{ 21 | id: "line-Middleborough", 22 | short_name: "", 23 | long_name: "Middleborough/Lakeville Line", 24 | description: "", 25 | color: "80276C", 26 | text_color: "FFFFFF", 27 | sort_order: 59 28 | } 29 | 30 | assert parse_row(row) == expected 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/parse/test/parse/alerts/calendar_dates_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.CalendarDatesTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Parse.CalendarDates 5 | use Timex 6 | 7 | test "parses calendar dates" do 8 | blob = """ 9 | "service_id","date","exception_type","holiday_name" 10 | "BUS12016-hba16011-Weekday-02","20160226",2,"Washington’s Birthday" 11 | "Boat-F4-Sunday","20150907",1, 12 | """ 13 | 14 | assert Parse.CalendarDates.parse(blob) == [ 15 | %CalendarDates{ 16 | service_id: "BUS12016-hba16011-Weekday-02", 17 | date: ~D[2016-02-26], 18 | added: false, 19 | holiday_name: "Washington’s Birthday" 20 | }, 21 | %CalendarDates{ 22 | service_id: "Boat-F4-Sunday", 23 | date: ~D[2015-09-07], 24 | added: true, 25 | holiday_name: "" 26 | } 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/fetch/lib/fetch.ex: -------------------------------------------------------------------------------- 1 | defmodule Fetch do 2 | @moduledoc """ 3 | Fetches URLs from the internet 4 | """ 5 | 6 | use DynamicSupervisor 7 | 8 | def start_link(opts \\ []) do 9 | DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) 10 | end 11 | 12 | def fetch_url(url, opts \\ []) do 13 | pid = 14 | case GenServer.whereis(Fetch.Worker.via_tuple(url)) do 15 | nil -> 16 | case start_child(url) do 17 | {:ok, pid} -> pid 18 | {:error, {:already_started, pid}} -> pid 19 | end 20 | 21 | pid -> 22 | pid 23 | end 24 | 25 | Fetch.Worker.fetch_url(pid, opts) 26 | end 27 | 28 | def start_child(url) do 29 | DynamicSupervisor.start_child(__MODULE__, {Fetch.Worker, url}) 30 | end 31 | 32 | @impl DynamicSupervisor 33 | def init(opts) do 34 | DynamicSupervisor.init( 35 | strategy: :one_for_one, 36 | extra_arguments: [opts] 37 | ) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/shared/login_form.html.heex: -------------------------------------------------------------------------------- 1 |

Login

2 | 3 |
4 | <%= form_for @changeset, @action, [], fn f -> %> 5 | <%= unless @changeset.valid? do %> 6 |
7 |

Oops, something went wrong! Please check the errors below.

8 |
9 | <% end %> 10 | 11 | <.form_group form={f} field={:email}> 12 | {label(f, :email, class: "control-label")} 13 | {email_input(f, :email, class: "form-control")} 14 | {error_tag(f, :email)} 15 | 16 | 17 | <.form_group form={f} field={:password}> 18 | {label(f, :password, class: "control-label")} 19 | {password_input(f, :password, class: "form-control", autocomplete: "off")} 20 | {error_tag(f, :password)} 21 | 22 | 23 |
24 | {submit("Login", class: "btn btn-primary")} 25 |
26 | <% end %> 27 |
28 | -------------------------------------------------------------------------------- /apps/state/lib/state/stop/subscriber.ex: -------------------------------------------------------------------------------- 1 | defmodule State.Stop.Subscriber do 2 | @moduledoc """ 3 | Subscribes to `{:fetch, "stops.txt"}` events and uses it to reload state in `State.Stop`. 4 | """ 5 | use Events.Server 6 | 7 | def start_link(_opts \\ []) do 8 | GenServer.start_link(__MODULE__, nil) 9 | end 10 | 11 | def stop(pid) do 12 | GenServer.stop(pid) 13 | end 14 | 15 | @impl Events.Server 16 | def handle_event({:fetch, "stops.txt"}, body, _, state) do 17 | :ok = 18 | body 19 | |> Parse.Stops.parse() 20 | |> State.Stop.new_state() 21 | 22 | {:noreply, state, :hibernate} 23 | end 24 | 25 | @impl GenServer 26 | def init(nil) do 27 | :ok = subscribe({:fetch, "stops.txt"}) 28 | 29 | if State.Stop.size() > 0 do 30 | # if we crashed and restarted, ensure that all the workers also have 31 | # the correct state 32 | State.Stop.new_state(State.Stop.all()) 33 | end 34 | 35 | {:ok, nil} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/model/lib/model/vehicle/carriage.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.Vehicle.Carriage do 2 | @moduledoc """ 3 | A carriage (segment) of a vehicle (for example, an individual car on a train), used for 4 | more detailed occupancy information. 5 | """ 6 | use Recordable, [ 7 | :label, 8 | :carriage_sequence, 9 | :occupancy_status, 10 | :occupancy_percentage 11 | ] 12 | 13 | @typedoc """ 14 | Carriage-level crowding details 15 | 16 | * `:label` - Carriage-specific label, used as an identifier 17 | * `:carriage_sequence` - Provides a reliable order 18 | * `:occupancy_status` - The degree of passenger occupancy for the vehicle. 19 | * `:occupancy_percentage` - Percentage of vehicle occupied, calculated via weight average 20 | """ 21 | @type t :: %__MODULE__{ 22 | label: String.t() | nil, 23 | carriage_sequence: String.t() | nil, 24 | occupancy_status: String.t() | nil, 25 | occupancy_percentage: String.t() | nil 26 | } 27 | end 28 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/polyline.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Polyline do 2 | @moduledoc """ 3 | Parses the latitude/longitude pairs from shapes.txt into a 4 | [polyline](https://developers.google.com/maps/documentation/utilities/polylinealgorithm) 5 | """ 6 | @behaviour Parse 7 | 8 | defstruct [:id, :polyline] 9 | 10 | def parse(blob) when is_binary(blob) do 11 | blob 12 | |> BinaryLineSplit.stream!() 13 | |> SimpleCSV.stream() 14 | |> Enum.group_by(& &1["shape_id"]) 15 | |> Enum.map(&parse_shape(elem(&1, 1))) 16 | end 17 | 18 | defp parse_shape([%{"shape_id" => id} | _] = points) do 19 | %__MODULE__{ 20 | id: id, 21 | polyline: polyline(points) 22 | } 23 | end 24 | 25 | defp polyline(points) do 26 | points 27 | |> Enum.map(fn %{"shape_pt_lat" => lat, "shape_pt_lon" => lon} -> 28 | {lon, ""} = Float.parse(lon) 29 | {lat, ""} = Float.parse(lat) 30 | {lon, lat} 31 | end) 32 | |> Polyline.encode() 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/facility.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Facility do 2 | @moduledoc """ 3 | 4 | Parser for elevators.csv 5 | 6 | """ 7 | use Parse.Simple 8 | 9 | alias Model.Facility 10 | 11 | def parse_row(row) do 12 | %Facility{ 13 | id: copy(row["facility_id"]), 14 | stop_id: copy(row["stop_id"]), 15 | long_name: optional_string(row["facility_long_name"]), 16 | short_name: optional_string(row["facility_short_name"]), 17 | type: type(row["facility_type"]), 18 | latitude: optional_latlng(row["facility_lat"]), 19 | longitude: optional_latlng(row["facility_lon"]) 20 | } 21 | end 22 | 23 | defp optional_latlng("") do 24 | nil 25 | end 26 | 27 | defp optional_latlng(value) do 28 | String.to_float(value) 29 | end 30 | 31 | defp optional_string(""), do: nil 32 | defp optional_string(value), do: value 33 | 34 | defp type(string) do 35 | string 36 | |> String.upcase() 37 | |> String.replace("-", "_") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/key/increase.html.heex: -------------------------------------------------------------------------------- 1 |

Request Rate Limit Increase

2 |
3 |
Key Details
4 |
5 |
    6 |
  • User E-mail: {@user.email}
  • 7 |
  • Key: {@key.key}
  • 8 |
9 |
10 |
11 | 12 |
13 | Can you tell us more about your app, and why you're looking for an increase? 14 |
15 |
16 | 17 | <%= form_for @conn, key_path(@conn, :do_request_increase, @key), [as: :reason, method: assigns[:method] || :post], fn f -> %> 18 |
19 | {label(f, :reason, class: "control-label")} 20 | {textarea(f, :reason, class: "form-control", required: "")} 21 |
22 | 23 |
24 | {submit("Request Increase", class: "btn btn-primary")} 25 |
26 | <% end %> 27 | 28 | {link("Back", to: portal_path(@conn, :index))} 29 | -------------------------------------------------------------------------------- /apps/api_web/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :api_web, ApiWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | config :api_web, :rate_limiter, 10 | max_anon_per_interval: 5, 11 | clear_interval: 100 12 | 13 | config :api_web, RateLimiter.Memcache, 14 | connection_opts: [ 15 | namespace: "api_test_rate_limit", 16 | hostname: "localhost" 17 | ] 18 | 19 | config :api_web, ApiWeb.Plugs.ModifiedSinceHandler, check_caller: true 20 | 21 | config :sentry, 22 | test_mode: true, 23 | before_send: {ApiWeb.SentryEventFilter, :filter_event} 24 | 25 | # Credentials that always show widget and pass backend validation: 26 | config :recaptcha, 27 | enabled: true, 28 | public_key: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", 29 | secret: "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" 30 | 31 | # Print only warnings and errors during test 32 | config :logger, level: :warning 33 | -------------------------------------------------------------------------------- /apps/parse/test/parse/timezone_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.TimezoneTest do 2 | @moduledoc false 3 | use ExUnit.Case 4 | import Parse.Timezone 5 | 6 | doctest Parse.Timezone 7 | 8 | @two_hours 2 * 60 * 60 9 | 10 | describe "unix_to_local/2" do 11 | test "when springing forward, returns a time with the same Unix epoch" do 12 | # Sunday, March 11, 2018 1:00:00 AM GMT-05:00 13 | start = 1_520_748_000 14 | 15 | for time <- start..(start + @two_hours), rem(time, 300) == 0 do 16 | actual = unix_to_local(time) 17 | assert DateTime.to_unix(actual) == time 18 | end 19 | end 20 | 21 | test "when falling back, returns a time with the same Unix epoch" do 22 | # Sunday, November 4, 2018 1:00:00 AM GMT-04:00 23 | start = 1_541_307_600 24 | 25 | for time <- start..(start + @two_hours), rem(time, 300) == 0 do 26 | actual = unix_to_local(time) 27 | assert DateTime.to_unix(actual) == time 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/views/route_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.RouteViewTest do 2 | use ApiWeb.ConnCase, async: true 3 | 4 | alias Model.Route 5 | 6 | test "attributes/2 uses struct's type and expected attributes" do 7 | route = %Route{ 8 | id: "red", 9 | type: 1, 10 | description: "desc", 11 | fare_class: "Ferry", 12 | short_name: "short", 13 | long_name: "long", 14 | sort_order: 1, 15 | color: "some color", 16 | text_color: "some text color" 17 | } 18 | 19 | expected = %{ 20 | type: 1, 21 | description: "desc", 22 | fare_class: "Ferry", 23 | short_name: "short", 24 | long_name: "long", 25 | direction_names: [nil, nil], 26 | direction_destinations: [nil, nil], 27 | sort_order: 1, 28 | color: "some color", 29 | text_color: "some text color", 30 | listed_route: nil 31 | } 32 | 33 | assert ApiWeb.RouteView.attributes(route, %Plug.Conn{}) == expected 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/events/lib/events/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Events.Server do 2 | @moduledoc """ 3 | Redirect `{:event, name, data, argument}` messages sent to a `GenServer` to a 4 | `handle_event(name, data, argument, state)` callback. 5 | """ 6 | 7 | @callback handle_event(name :: term, data :: term, argument :: term, state :: term) :: 8 | {:noreply, new_state} 9 | | {:noreply, new_state, timeout | :hibernate} 10 | | {:stop, reason :: term, new_state} 11 | when new_state: term 12 | 13 | defmacro __using__(_) do 14 | quote location: :keep do 15 | use GenServer 16 | import Events 17 | 18 | @behaviour Events.Server 19 | 20 | @impl GenServer 21 | def handle_info({:event, name, data, argument}, state) do 22 | handle_event(name, data, argument, state) 23 | end 24 | 25 | def handle_info(_, state) do 26 | {:noreply, state} 27 | end 28 | 29 | defoverridable handle_info: 2 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/rate_limiter/memcache.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.RateLimiter.Memcache do 2 | @moduledoc """ 3 | RateLimiter backend which uses Memcache as a backend. 4 | """ 5 | @behaviour ApiWeb.RateLimiter.Limiter 6 | alias ApiWeb.RateLimiter.Memcache.Supervisor 7 | 8 | @impl ApiWeb.RateLimiter.Limiter 9 | def start_link(opts) do 10 | clear_interval_ms = Keyword.fetch!(opts, :clear_interval) 11 | clear_interval = div(clear_interval_ms, 1000) 12 | 13 | connection_opts = 14 | [ttl: clear_interval * 2] ++ ApiWeb.config(RateLimiter.Memcache, :connection_opts) 15 | 16 | Supervisor.start_link(connection_opts) 17 | end 18 | 19 | @impl ApiWeb.RateLimiter.Limiter 20 | def rate_limited?(key, max_requests) do 21 | case Supervisor.decr(key, default: max_requests) do 22 | {:ok, 0} -> 23 | :rate_limited 24 | 25 | {:ok, n} when is_integer(n) -> 26 | {:remaining, n - 1} 27 | 28 | _ -> 29 | {:remaining, max_requests} 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/state_mediator/test/state_mediator/mediator_log_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateMediator.MediatorLogTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureLog, only: [capture_log: 1] 4 | alias StateMediator.Mediator 5 | 6 | test "logs a fetch timeout as a warning" do 7 | assert capture_log(fn -> 8 | Mediator.handle_response( 9 | {:error, 10 | %HTTPoison.Error{ 11 | id: nil, 12 | reason: :timeout 13 | }}, 14 | %{module: nil, retries: 0} 15 | ) 16 | end) =~ "[warning]" 17 | end 18 | 19 | test "logs an unknown error as an error" do 20 | assert capture_log(fn -> 21 | Mediator.handle_response( 22 | {:error, 23 | %HTTPoison.Error{ 24 | id: nil, 25 | reason: :heat_death_of_universe 26 | }}, 27 | %{module: nil, retries: 0} 28 | ) 29 | end) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/edit.html.heex: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /apps/parse/test/parse/route_patterns_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.RoutePatternsTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | 5 | describe "parse/1" do 6 | test "parses a CSV blob into a list of %RoutePattern{} structs" do 7 | blob = ~s( 8 | route_pattern_id,route_id,direction_id,route_pattern_name,route_pattern_time_desc,route_pattern_typicality,route_pattern_sort_order,representative_trip_id,canonical_route_pattern 9 | Red-1-0,Red,0,Ashmont,,1,10010051,38899721-21:00-KL,1 10 | ) 11 | 12 | assert Parse.RoutePatterns.parse(blob) == [ 13 | %Model.RoutePattern{ 14 | id: "Red-1-0", 15 | route_id: "Red", 16 | direction_id: 0, 17 | name: "Ashmont", 18 | time_desc: nil, 19 | typicality: 1, 20 | sort_order: 10_010_051, 21 | representative_trip_id: "38899721-21:00-KL", 22 | canonical: true 23 | } 24 | ] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/parse/test/parse/calendar_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.CalendarTest do 2 | use ExUnit.Case, async: true 3 | alias Parse.Calendar 4 | use Timex 5 | 6 | test "parses calendar entries" do 7 | blob = """ 8 | "service_id","monday","tuesday","wednesday","thursday","friday","saturday","sunday","start_date","end_date" 9 | "BUS22016-hba26ns1-Weekday-02",1,1,1,1,1,0,0,"20160418","20160422" 10 | "BUS12016-hbb16hl6-Saturday-02",0,0,0,0,0,1,1,"20160215","20160215" 11 | """ 12 | 13 | assert Parse.Calendar.parse(blob) == [ 14 | %Calendar{ 15 | service_id: "BUS22016-hba26ns1-Weekday-02", 16 | days: [1, 2, 3, 4, 5], 17 | start_date: ~D[2016-04-18], 18 | end_date: ~D[2016-04-22] 19 | }, 20 | %Calendar{ 21 | service_id: "BUS12016-hbb16hl6-Saturday-02", 22 | days: [6, 7], 23 | start_date: ~D[2016-02-15], 24 | end_date: ~D[2016-02-15] 25 | } 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/model/test/recordable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RecordableTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Example do 5 | use Recordable, [:key, :val] 6 | end 7 | 8 | defmodule ExampleWithPairs do 9 | use Recordable, key: :key, val: :val 10 | end 11 | 12 | alias RecordableTest.{Example, ExampleWithPairs} 13 | 14 | test "to_record/1" do 15 | assert Example.to_record(%Example{key: :key, val: :val}) == {Example, :key, :val} 16 | 17 | assert ExampleWithPairs.to_record(%ExampleWithPairs{key: :other}) == 18 | {ExampleWithPairs, :other, :val} 19 | end 20 | 21 | test "from_record/1" do 22 | assert Example.from_record({Example, :key, :val}) == %Example{key: :key, val: :val} 23 | 24 | assert ExampleWithPairs.from_record({ExampleWithPairs, :key, :val}) == %ExampleWithPairs{ 25 | key: :key, 26 | val: :val 27 | } 28 | end 29 | 30 | test "fields/0" do 31 | assert Example.fields() == [:key, :val] 32 | assert ExampleWithPairs.fields() == [:key, :val] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/api_accounts/test/support/test_tables.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAccounts.Test.Model do 2 | @moduledoc false 3 | use ApiAccounts.Table 4 | 5 | table "model_table" do 6 | field(:email, :string, primary_key: true) 7 | field(:username, :string, secondary_index: true) 8 | field(:name, :string) 9 | field(:active, :boolean, default: true) 10 | field(:secret, :string, virtual: true) 11 | schema_version(1) 12 | end 13 | end 14 | 15 | defmodule ApiAccounts.Test.ModelWithoutSecondary do 16 | @moduledoc false 17 | use ApiAccounts.Table 18 | 19 | table "model_without_secondary_table" do 20 | field(:email, :string, primary_key: true) 21 | field(:name, :string) 22 | field(:secret, :string, virtual: true) 23 | schema_version(1) 24 | end 25 | end 26 | 27 | defmodule ApiAccounts.Test.MigrationModel do 28 | @moduledoc false 29 | use ApiAccounts.Table 30 | 31 | table "migration_test" do 32 | field(:id, :string, primary_key: true) 33 | field(:date, :datetime) 34 | field(:name, :string) 35 | schema_version(2) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/parse/test/parse/facility/property_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.Facility.PropertyTest do 2 | use ExUnit.Case, async: true 3 | import Parse.Facility.Property 4 | alias Model.Facility.Property 5 | 6 | describe "parse/1" do 7 | test "parses a CSV blob into a list of %Facility.Property{} structs" do 8 | blob = ~s( 9 | "facility_id","property_id","value" 10 | "park-001","enclosed","2") 11 | 12 | assert parse(blob) == [ 13 | %Property{ 14 | name: "enclosed", 15 | facility_id: "park-001", 16 | value: 2 17 | } 18 | ] 19 | end 20 | 21 | test "doesn't decode non-numeric values" do 22 | blob = ~s( 23 | "facility_id","property_id","value" 24 | "park-001","operator","Republic Parking System") 25 | 26 | assert parse(blob) == [ 27 | %Property{ 28 | name: "operator", 29 | facility_id: "park-001", 30 | value: "Republic Parking System" 31 | } 32 | ] 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/rate_limiter/memcache/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.RateLimiter.Memcache.Supervisor do 2 | @moduledoc """ 3 | Supervisor for multiple connections to a Memcache instance. 4 | """ 5 | @worker_count 5 6 | @registry_name __MODULE__.Registry 7 | 8 | def start_link(connection_opts) do 9 | registry = {Registry, keys: :unique, name: @registry_name} 10 | 11 | workers = 12 | for i <- 1..@worker_count do 13 | Supervisor.child_spec({Memcache, [connection_opts, [name: worker_name(i)]]}, id: i) 14 | end 15 | 16 | children = [registry | workers] 17 | 18 | Supervisor.start_link( 19 | children, 20 | strategy: :rest_for_one, 21 | name: __MODULE__ 22 | ) 23 | end 24 | 25 | @doc "Decrement a given key, using a random child." 26 | def decr(key, opts) do 27 | Memcache.decr(random_child(), key, opts) 28 | end 29 | 30 | defp worker_name(index) do 31 | {:via, Registry, {@registry_name, index}} 32 | end 33 | 34 | defp random_child do 35 | worker_name(:rand.uniform(@worker_count)) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/fetch/test/fetch/worker_log_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Fetch.WorkerLogTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureLog, only: [capture_log: 1] 4 | 5 | import Plug.Conn 6 | 7 | setup do 8 | lasso = Lasso.open() 9 | url = "http://localhost:#{lasso.port}" 10 | {:ok, pid} = Fetch.Worker.start_link(url) 11 | {:ok, %{lasso: lasso, pid: pid, url: url}} 12 | end 13 | 14 | test "logs a fetch timeout as a warning", %{lasso: lasso, pid: pid} do 15 | Lasso.expect(lasso, "GET", "/", fn conn -> 16 | Process.sleep(1000) 17 | 18 | conn 19 | |> resp(200, "body") 20 | end) 21 | 22 | assert capture_log(fn -> 23 | Fetch.Worker.fetch_url(pid, timeout: 100) 24 | end) =~ "[warning]" 25 | end 26 | 27 | test "logs an unknown error as an error", %{lasso: lasso, pid: pid} do 28 | Lasso.expect(lasso, "GET", "/", fn conn -> 29 | conn 30 | |> resp(500, "") 31 | end) 32 | 33 | assert capture_log(fn -> 34 | Fetch.Worker.fetch_url(pid, timeout: 100) 35 | end) =~ "[error]" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/state/test/state/stop/list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.Stop.ListTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | use ExUnitProperties 5 | alias Model.Stop 6 | alias State.Stop.List, as: StopList 7 | 8 | test "around searches around a geo point" do 9 | stop = %Stop{id: "1", name: "stop", latitude: 1, longitude: -2} 10 | list = StopList.new([stop]) 11 | 12 | assert StopList.around(list, 1.001, -2.002) == ["1"] 13 | assert StopList.around(list, -1.001, 2.002) == [] 14 | assert StopList.around(list, 1.001, -2.002, 0.001) == [] 15 | end 16 | 17 | property "does not crash when provided stops" do 18 | check all(stops <- list_of(stop())) do 19 | StopList.new(stops) 20 | end 21 | end 22 | 23 | defp stop do 24 | # generate stops, some of which don't have a location 25 | gen all( 26 | id <- string(:ascii), 27 | {latitude, longitude} <- one_of([tuple({float(), float()}), constant({nil, nil})]) 28 | ) do 29 | %Stop{id: id, latitude: latitude, longitude: longitude} 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/api_web/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :api_web, ApiWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | live_reload: [ 15 | patterns: [ 16 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 17 | ~r{lib/api_web/views/.*(ex)$}, 18 | ~r{lib/api_web/templates/.*(eex)$} 19 | ] 20 | ] 21 | 22 | config :api_web, ApiWeb.Plugs.ModifiedSinceHandler, check_caller: true 23 | 24 | # Do not include metadata nor timestamps in development logs 25 | config :logger, :console, format: "[$level] $message\n", level: :debug 26 | 27 | # Set a higher stacktrace during development. 28 | # Do not configure such in production as keeping 29 | # and calculating stacktraces is usually expensive. 30 | config :phoenix, :stacktrace_depth, 20 31 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/request_track.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RequestTrack do 2 | @moduledoc """ 3 | Track the number of concurrent requests made by a given API key or IP address. 4 | """ 5 | @behaviour Plug 6 | import Plug.Conn, only: [register_before_send: 2] 7 | 8 | @impl Plug 9 | def init(opts) do 10 | Keyword.fetch!(opts, :name) 11 | end 12 | 13 | @impl Plug 14 | @doc """ 15 | Track the API user, and decrement the count before sending. 16 | 17 | We increment the count when we're initially called, and set up a callback 18 | to decrement the count before the response is sent. 19 | """ 20 | def call(conn, table_name) do 21 | key = conn.assigns.api_user 22 | RequestTrack.increment(table_name, key) 23 | _ = Logger.metadata(concurrent: RequestTrack.count(table_name, key)) 24 | 25 | register_before_send(conn, fn conn -> 26 | # don't decrement if we're using chunked requests (streaming) 27 | if conn.state == :set do 28 | RequestTrack.decrement(table_name) 29 | end 30 | 31 | conn 32 | end) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/redirect_already_authenticated_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RedirectAlreadyAuthenticatedTest do 2 | use ApiWeb.ConnCase, async: true 3 | 4 | test "init" do 5 | assert ApiWeb.Plugs.RedirectAlreadyAuthenticated.init([]) == [] 6 | end 7 | 8 | setup %{conn: conn} do 9 | conn = 10 | conn 11 | |> conn_with_session() 12 | |> bypass_through(ApiWeb.Router, [:browser]) 13 | 14 | {:ok, %{conn: conn}} 15 | end 16 | 17 | test "redirects when user already authenticated", %{conn: conn} do 18 | conn = 19 | conn 20 | |> assign(:user, %ApiAccounts.User{}) 21 | |> get("/") 22 | |> ApiWeb.Plugs.RedirectAlreadyAuthenticated.call([]) 23 | 24 | assert redirected_to(conn) == portal_path(conn, :index) 25 | assert conn.halted 26 | end 27 | 28 | test "does not redirect when user isn't authenticated", %{conn: conn} do 29 | conn = 30 | conn 31 | |> get("/") 32 | |> ApiWeb.Plugs.RedirectAlreadyAuthenticated.call([]) 33 | 34 | refute conn.status 35 | refute conn.halted 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/route_patterns.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.RoutePatterns do 2 | @moduledoc false 3 | use Parse.Simple 4 | 5 | @spec parse_row(%{optional(String.t()) => term()}) :: Model.RoutePattern.t() 6 | def parse_row(row) do 7 | %Model.RoutePattern{ 8 | id: copy_string(row["route_pattern_id"]), 9 | route_id: copy_string(row["route_id"]), 10 | direction_id: copy_int(row["direction_id"]), 11 | name: copy_string(row["route_pattern_name"]), 12 | time_desc: copy_string(row["route_pattern_time_desc"]), 13 | typicality: copy_int(row["route_pattern_typicality"]), 14 | sort_order: copy_int(row["route_pattern_sort_order"]), 15 | representative_trip_id: copy_string(row["representative_trip_id"]), 16 | canonical: parse_canonical(row["canonical_route_pattern"]) 17 | } 18 | end 19 | 20 | defp copy_string(""), do: nil 21 | defp copy_string(s), do: :binary.copy(s) 22 | 23 | defp copy_int(""), do: nil 24 | defp copy_int(s), do: String.to_integer(s) 25 | 26 | defp parse_canonical("1"), do: true 27 | defp parse_canonical(_), do: false 28 | end 29 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/controllers/mfa_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.MFAController do 2 | @moduledoc false 3 | use ApiWeb.Web, :controller 4 | 5 | def new(conn, _params) do 6 | user = conn |> get_session(:inc_user_id) |> ApiAccounts.get_user!() 7 | change = ApiAccounts.change_user(user) 8 | 9 | conn 10 | |> render("new.html", changeset: change) 11 | end 12 | 13 | def create(conn, params) do 14 | user = conn |> get_session(:inc_user_id) |> ApiAccounts.get_user!() 15 | 16 | case ApiAccounts.validate_totp(user, params["user"]["totp_code"]) do 17 | {:ok, user} -> 18 | destination = get_session(conn, :destination) 19 | 20 | conn 21 | |> delete_session(:inc_user_id) 22 | |> put_session(:user_id, user.id) 23 | |> configure_session(renew: true) 24 | |> delete_session(:destination) 25 | |> redirect(to: destination) 26 | 27 | {:error, changeset} -> 28 | conn 29 | |> put_flash(:error, "Invalid code. Please try again.") 30 | |> render("new.html", changeset: changeset) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/fetch_user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.FetchUserTest do 2 | use ApiWeb.ConnCase, async: false 3 | 4 | test "init" do 5 | opts = [] 6 | assert ApiWeb.Plugs.FetchUser.init(opts) == opts 7 | end 8 | 9 | setup %{conn: conn} do 10 | ApiAccounts.Dynamo.create_table(ApiAccounts.User) 11 | on_exit(fn -> ApiAccounts.Dynamo.delete_all_tables() end) 12 | {:ok, user} = ApiAccounts.create_user(%{email: "test@example.com"}) 13 | 14 | conn = 15 | conn 16 | |> conn_with_session() 17 | |> bypass_through(ApiWeb.Router, [:browser]) 18 | 19 | {:ok, %{conn: conn, user: user}} 20 | end 21 | 22 | test "fetches user when id present in session", %{conn: conn, user: user} do 23 | conn = 24 | conn 25 | |> Plug.Conn.put_session(:user_id, user.id) 26 | |> ApiWeb.Plugs.FetchUser.call([]) 27 | 28 | assert conn.assigns.user == user 29 | end 30 | 31 | test "doesn't assign when no user id in session", %{conn: conn} do 32 | conn = ApiWeb.Plugs.FetchUser.call(conn, []) 33 | refute conn.assigns[:user] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/key/form.html.heex: -------------------------------------------------------------------------------- 1 | <%= form_for @changeset, @action, [method: assigns[:method] || :post], fn f -> %> 2 | <%= unless @changeset.valid? do %> 3 |
4 |

Oops, something went wrong! Please check the errors below.

5 |
6 | <% end %> 7 | 8 |
9 | {label(f, :description, class: "control-label")} 10 | {text_input(f, :description, class: "form-control")} 11 | {error_tag(f, :description)} 12 |
13 | 14 |
15 | {label(f, :api_version, class: "control-label")} 16 | {select(f, :api_version, @api_versions, class: "form-control")} 17 | {error_tag(f, :api_version)} 18 |
19 | 20 |
21 | {label(f, "Allowed domains (comma separated list of domains or *)", class: "control-label")} 22 | {text_input(f, :allowed_domains, class: "form-control")} 23 | {error_tag(f, :allowed_domains)} 24 |
25 | 26 |
27 | {submit("Submit", class: "btn btn-primary")} 28 |
29 | <% end %> 30 | -------------------------------------------------------------------------------- /apps/health/lib/health/checkers/real_time.ex: -------------------------------------------------------------------------------- 1 | defmodule Health.Checkers.RealTime do 2 | @moduledoc """ 3 | Health check which makes sure that real-time data (vehicle positions and 4 | predictions) aren't stale. 5 | """ 6 | @stale_data_seconds 15 * 60 7 | @current_time_fetcher &DateTime.utc_now/0 8 | 9 | def current(current_time_fetcher \\ @current_time_fetcher) do 10 | updated_timestamps = State.Metadata.updated_timestamps() 11 | current_time = current_time_fetcher.() 12 | 13 | [ 14 | prediction: updated_timestamps.prediction, 15 | prediction_diff: DateTime.diff(current_time, updated_timestamps.prediction), 16 | vehicle: updated_timestamps.vehicle, 17 | vehicle_diff: DateTime.diff(current_time, updated_timestamps.vehicle) 18 | ] 19 | end 20 | 21 | def healthy?(current_time_fetcher \\ @current_time_fetcher) do 22 | updated_timestamps = State.Metadata.updated_timestamps() 23 | current_time = current_time_fetcher.() 24 | 25 | Enum.all?([:prediction, :vehicle], fn feed -> 26 | DateTime.diff(current_time, updated_timestamps[feed]) < @stale_data_seconds 27 | end) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev-green.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Dev-green (ECS) 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | deploy: 7 | name: Deploy 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | contents: read 12 | environment: dev-green 13 | concurrency: dev-green 14 | env: 15 | ECS_CLUSTER: api 16 | ECS_SERVICE: api-dev-green 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: mbta/actions/build-push-ecr@v2 21 | id: build-push 22 | with: 23 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 24 | docker-repo: ${{ secrets.DOCKER_REPO }} 25 | - uses: mbta/actions/deploy-ecs@v2 26 | with: 27 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 28 | ecs-cluster: ${{ env.ECS_CLUSTER }} 29 | ecs-service: ${{ env.ECS_SERVICE }} 30 | docker-tag: ${{ steps.build-push.outputs.docker-tag }} 31 | - uses: mbta/actions/notify-slack-deploy@v1 32 | if: ${{ !cancelled() }} 33 | with: 34 | webhook-url: ${{ secrets.SLACK_WEBHOOK }} 35 | job-status: ${{ job.status }} 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prod.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Prod (ECS) 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | deploy: 7 | name: Deploy 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | contents: read 12 | environment: prod 13 | concurrency: prod 14 | env: 15 | ECS_CLUSTER: api 16 | ECS_SERVICE: api-prod 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: mbta/actions/build-push-ecr@v2 21 | id: build-push 22 | with: 23 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 24 | docker-repo: ${{ secrets.DOCKER_REPO }} 25 | - name: Deploy to ECS 26 | uses: mbta/actions/deploy-ecs@v2 27 | with: 28 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 29 | ecs-cluster: ${{ env.ECS_CLUSTER }} 30 | ecs-service: ${{ env.ECS_SERVICE }} 31 | docker-tag: ${{ steps.build-push.outputs.docker-tag }} 32 | - uses: mbta/actions/notify-slack-deploy@v1 33 | if: ${{ !cancelled() }} 34 | with: 35 | webhook-url: ${{ secrets.SLACK_WEBHOOK }} 36 | job-status: ${{ job.status }} 37 | -------------------------------------------------------------------------------- /apps/state/test/state/stop/subscriber_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.Stop.SubscriberTest do 2 | @moduledoc false 3 | use ExUnit.Case 4 | import State.Stop.Subscriber 5 | 6 | describe "after a restart" do 7 | setup do 8 | # only put the stop into the cache, to simulate the worker not 9 | # receiving the information on startup 10 | State.Stop.Cache.new_state([ 11 | %Model.Stop{latitude: 42.0, longitude: -71.0} 12 | ]) 13 | 14 | :ok 15 | end 16 | 17 | test "ensures the workers have the right state" do 18 | Events.subscribe({:new_state, State.Stop}) 19 | 20 | subscriber_pid = 21 | State.Stop 22 | |> Supervisor.which_children() 23 | |> Enum.find_value(fn {name, pid, _, _} -> 24 | if name == State.Stop.Subscriber, do: pid 25 | end) 26 | 27 | stop(subscriber_pid) 28 | 29 | # wait for the new_state event 30 | receive do 31 | {:event, _, _, _} -> :ok 32 | end 33 | 34 | # restarts subscriber, should pass state to the workers as well 35 | assert [_] = State.Stop.Worker.around(1, 42.0, -71.0) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Massachusetts Bay Transportation Authority 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/edit_password.html.heex: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Dev (ECS) 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | contents: read 15 | environment: dev 16 | concurrency: dev 17 | env: 18 | ECS_CLUSTER: api 19 | ECS_SERVICE: api-dev 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: mbta/actions/build-push-ecr@v2 24 | id: build-push 25 | with: 26 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 27 | docker-repo: ${{ secrets.DOCKER_REPO }} 28 | - uses: mbta/actions/deploy-ecs@v2 29 | with: 30 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 31 | ecs-cluster: ${{ env.ECS_CLUSTER }} 32 | ecs-service: ${{ env.ECS_SERVICE }} 33 | docker-tag: ${{ steps.build-push.outputs.docker-tag }} 34 | - uses: mbta/actions/notify-slack-deploy@v1 35 | if: ${{ !cancelled() }} 36 | with: 37 | webhook-url: ${{ secrets.SLACK_WEBHOOK }} 38 | job-status: ${{ job.status }} 39 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/client_portal/user/reset_password.html.heex: -------------------------------------------------------------------------------- 1 |

Reset Password

2 | 3 |
4 | <%= form_for @changeset, user_path(@conn, :reset_password_submit, token: @token), [], fn f -> %> 5 | <%= if @changeset.action do %> 6 |
7 |

Oops, something went wrong! Please check the errors below.

8 |
9 | <% end %> 10 | 11 | <.form_group form={f} field={:password}> 12 | {label(f, :password, class: "control-label")} 13 | {password_input(f, :password, class: "form-control", autocomplete: "off")} 14 | {error_tag(f, :password)} 15 | 16 | 17 | <.form_group form={f} field={:password_confirmation}> 18 | {label(f, :password_confirmation, class: "control-label")} 19 | {password_input(f, :password_confirmation, class: "form-control", autocomplete: "off")} 20 | {error_tag(f, :password_confirmation)} 21 | 22 | 23 |
24 | {submit("Submit", class: "btn btn-primary")} 25 |
26 | <% end %> 27 |
28 | -------------------------------------------------------------------------------- /apps/parse/test/parse/facility_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.FacilityTest do 2 | use ExUnit.Case, async: true 3 | import Parse.Facility 4 | alias Model.Facility 5 | 6 | setup do 7 | blob = ~s( 8 | "facility_id","facility_code","facility_class","facility_type","stop_id","facility_short_name","facility_long_name","facility_desc","facility_lat","facility_lon","wheelchair_facility" 9 | "pick-qnctr-busway","","3","pick-drop","place-qnctr","Hancock Street","Quincy Center Hancock Street Pick-up/Drop-off","","42.251716","-71.004715","1" 10 | ) 11 | 12 | {:ok, %{blob: blob}} 13 | end 14 | 15 | describe "parse/1" do 16 | test "parses a CSV blob into a list of %Facility{} structs", %{blob: blob} do 17 | assert parse(blob) == [ 18 | %Facility{ 19 | id: "pick-qnctr-busway", 20 | stop_id: "place-qnctr", 21 | type: "PICK_DROP", 22 | long_name: "Quincy Center Hancock Street Pick-up/Drop-off", 23 | short_name: "Hancock Street", 24 | latitude: 42.251716, 25 | longitude: -71.004715 26 | } 27 | ] 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/live_facility_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.LiveFacilityView do 2 | @moduledoc """ 3 | View for live Facility data like parking 4 | """ 5 | use ApiWeb.Web, :api_view 6 | 7 | location(:live_facility_location) 8 | 9 | def live_facility_location(lf, conn), do: live_facility_path(conn, :show, lf.facility_id) 10 | 11 | has_one( 12 | :facility, 13 | type: :facility, 14 | serializer: ApiWeb.FacilityView 15 | ) 16 | 17 | attributes([:properties, :updated_at]) 18 | 19 | def type(_, %{assigns: %{api_version: ver}}) when ver < "2019-07-01", do: "live-facility" 20 | def type(_, _), do: "live_facility" 21 | 22 | def attributes(%{properties: properties, updated_at: updated_at}, _conn) do 23 | %{ 24 | properties: Enum.map(properties, &property/1), 25 | updated_at: updated_at 26 | } 27 | end 28 | 29 | def id(%{facility_id: id}, _conn), do: id 30 | 31 | def facility(%{facility_id: id}, conn) do 32 | optional_relationship("facility", id, &State.Facility.by_id/1, conn) 33 | end 34 | 35 | defp property(property) do 36 | %{ 37 | name: property.name, 38 | value: property.value 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /apps/model/lib/geo_distance.ex: -------------------------------------------------------------------------------- 1 | defmodule GeoDistance do 2 | @moduledoc """ 3 | Helper functions for working with geographic distances. 4 | """ 5 | @degrees_to_radians 0.0174533 6 | @twice_earth_radius_miles 7918 7 | 8 | @doc "Returns the Haversine distance (in miles) between two latitude/longitude pairs" 9 | @spec distance(number, number, number, number) :: float 10 | def distance(latitude, longitude, latitude2, longitude2) do 11 | # Haversine distance 12 | a = 13 | 0.5 - :math.cos((latitude2 - latitude) * @degrees_to_radians) / 2 + 14 | :math.cos(latitude * @degrees_to_radians) * :math.cos(latitude2 * @degrees_to_radians) * 15 | (1 - :math.cos((longitude2 - longitude) * @degrees_to_radians)) / 2 16 | 17 | @twice_earth_radius_miles * :math.asin(:math.sqrt(a)) 18 | end 19 | 20 | @doc "Returns a comparison function based on the distance between two points" 21 | @spec cmp(number, number) :: (%{latitude: number, longitude: number} -> float) 22 | def cmp(latitude, longitude) do 23 | fn %{latitude: latitude2, longitude: longitude2} -> 24 | GeoDistance.distance(latitude, longitude, latitude2, longitude2) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/api_accounts/key.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAccounts.Key do 2 | @moduledoc """ 3 | Representation of an API key belonging to a User. 4 | """ 5 | use ApiAccounts.Table 6 | import ApiAccounts.Changeset 7 | 8 | @typedoc """ 9 | Primary key for `ApiAccounts.Key.t` 10 | """ 11 | @type key :: String.t() 12 | 13 | table "api_accounts_keys" do 14 | field(:key, :string, primary_key: true) 15 | field(:user_id, :string, secondary_index: true) 16 | field(:description, :string, default: nil) 17 | field(:created, :datetime) 18 | field(:requested_date, :datetime) 19 | field(:approved, :boolean, default: false) 20 | field(:locked, :boolean, default: false) 21 | field(:daily_limit, :integer) 22 | field(:rate_request_pending, :boolean, default: false) 23 | field(:api_version, :string) 24 | field(:allowed_domains, :string, default: "*") 25 | schema_version(3) 26 | end 27 | 28 | @doc false 29 | def changeset(struct, params \\ %{}) do 30 | fields = ~w( 31 | created requested_date approved locked daily_limit rate_request_pending api_version description allowed_domains 32 | )a 33 | cast(struct, params, fields) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/api_accounts/migrations/migrations.ex: -------------------------------------------------------------------------------- 1 | defimpl ApiAccounts.Migrations.SchemaMigration, for: ApiAccounts.User do 2 | alias ApiAccounts.User 3 | 4 | def migrate(%User{join_date: nil} = user, 0, 1), do: user 5 | 6 | def migrate(%User{join_date: join_date} = user, 0, 1) do 7 | join_date = DateTime.from_naive!(join_date, "Etc/UTC") 8 | %User{user | join_date: join_date, schema_version: 1} 9 | end 10 | end 11 | 12 | defimpl ApiAccounts.Migrations.SchemaMigration, for: ApiAccounts.Key do 13 | alias ApiAccounts.Key 14 | 15 | def migrate(%Key{} = key, 0, 1) do 16 | created = 17 | if key.created do 18 | DateTime.from_naive!(key.created, "Etc/UTC") 19 | end 20 | 21 | requested_date = 22 | if key.requested_date do 23 | DateTime.from_naive!(key.requested_date, "Etc/UTC") 24 | end 25 | 26 | %Key{key | created: created, requested_date: requested_date, schema_version: 1} 27 | end 28 | 29 | def migrate(%Key{} = key, 1, 2) do 30 | %Key{key | rate_request_pending: false, schema_version: 2} 31 | end 32 | 33 | def migrate(%Key{} = key, 2, 3) do 34 | %Key{key | api_version: "2017-11-28", schema_version: 3} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/events/lib/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Events do 2 | @moduledoc """ 3 | A simple wrapper around Registry for event pub/sub. 4 | 5 | Example: 6 | 7 | iex> Events.subscribe(:name, :argument) 8 | :ok 9 | iex> Events.publish(:name, :data) 10 | :ok 11 | iex> receive do 12 | ...> x -> x 13 | ...> end 14 | {:event, :name, :data, :argument} 15 | 16 | """ 17 | 18 | @type name :: any 19 | 20 | @doc """ 21 | Subscribes the process to the given event. Whenever the event is triggered, 22 | the process will receive a message tuple: 23 | 24 | {:event, , , } 25 | 26 | """ 27 | @spec subscribe(name, any) :: :ok 28 | def subscribe(name, argument \\ nil) do 29 | {:ok, _} = Registry.register(Events.Registry, name, argument) 30 | :ok 31 | end 32 | 33 | @doc """ 34 | Publishes an event to any subscribers. 35 | """ 36 | @spec publish(name, any) :: :ok 37 | def publish(name, data) do 38 | Registry.dispatch(Events.Registry, name, fn entries -> 39 | for {pid, argument} <- entries do 40 | send(pid, {:event, name, data, argument}) 41 | end 42 | end) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /apps/model/lib/model/facility.ex: -------------------------------------------------------------------------------- 1 | defmodule Model.Facility do 2 | @moduledoc """ 3 | Station amenities such as elevators, escalators, parking lots and bike storage. 4 | """ 5 | 6 | use Recordable, [ 7 | :id, 8 | :stop_id, 9 | :type, 10 | :long_name, 11 | :short_name, 12 | :latitude, 13 | :longitude 14 | ] 15 | 16 | alias Model.WGS84 17 | 18 | @type id :: String.t() 19 | 20 | @typedoc """ 21 | * `:id` - Unique ID 22 | * `:long_name` - Descriptive name of facility which can be used without any additional context. 23 | * `:short_name` - Short name of facility which might not include its station or type. 24 | * `:stop_id` - The `Model.Stop.id` of the station where facility is. 25 | * `:type` - What kind of amenity the facility is. 26 | * `:latitude` - Latitude of the facility 27 | * `:longitude` - Longitude of the facility 28 | """ 29 | @type t :: %__MODULE__{ 30 | id: id, 31 | long_name: String.t() | nil, 32 | short_name: String.t() | nil, 33 | stop_id: Model.Stop.id(), 34 | type: String.t(), 35 | latitude: WGS84.latitude() | nil, 36 | longitude: WGS84.longitude() | nil 37 | } 38 | end 39 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/line.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Line do 2 | @moduledoc """ 3 | Parses `lines.txt` CSV from GTFS zip 4 | 5 | line_id,line_short_name,line_long_name,line_desc,line_url,line_color,line_text_color,line_sort_order 6 | line-Red,,Red Line,,,DA291C,FFFFFF,1 7 | """ 8 | 9 | use Parse.Simple 10 | alias Model.Line 11 | 12 | @doc """ 13 | Parses (non-header) row of `lines.txt` 14 | 15 | ## Columns 16 | 17 | * `"line_id"` - `Model.Line.t` `id` 18 | * `"line_short_name"` - `Model.Line.t` `short_name` 19 | * `"line_long_name"` - `Model.Line.t` `long_name` 20 | * `"line_desc"` - `Model.Line.t` `description` 21 | * `"line_color"` - `Model.Line.t` `color` 22 | * `"line_text_color"` - `Model.Line.t` `text_color` 23 | * `"line_sort_order"` - `Model.Line.t` `sort_order` 24 | 25 | """ 26 | def parse_row(row) do 27 | %Line{ 28 | id: copy(row["line_id"]), 29 | short_name: copy(row["line_short_name"]), 30 | long_name: copy(row["line_long_name"]), 31 | description: copy(row["line_desc"]), 32 | color: copy(row["line_color"]), 33 | text_color: copy(row["line_text_color"]), 34 | sort_order: String.to_integer(row["line_sort_order"]) 35 | } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev-blue.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Dev-blue (ECS) 2 | 3 | on: 4 | schedule: 5 | - cron: '0 5 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | contents: read 15 | environment: dev-blue 16 | concurrency: dev-blue 17 | if: github.repository_owner == 'mbta' 18 | env: 19 | ECS_CLUSTER: api 20 | ECS_SERVICE: api-dev-blue 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: mbta/actions/build-push-ecr@v2 25 | id: build-push 26 | with: 27 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 28 | docker-repo: ${{ secrets.DOCKER_REPO }} 29 | - uses: mbta/actions/deploy-ecs@v2 30 | with: 31 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 32 | ecs-cluster: ${{ env.ECS_CLUSTER }} 33 | ecs-service: ${{ env.ECS_SERVICE }} 34 | docker-tag: ${{ steps.build-push.outputs.docker-tag }} 35 | - uses: mbta/actions/notify-slack-deploy@v1 36 | if: ${{ !cancelled() }} 37 | with: 38 | webhook-url: ${{ secrets.SLACK_WEBHOOK }} 39 | job-status: ${{ job.status }} 40 | -------------------------------------------------------------------------------- /apps/model/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :model, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:model, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /apps/events/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :events, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:events, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /apps/parse/test/parse/trip_updates_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.TripUpdatesTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | import Parse.GtfsRt.TripUpdates 5 | 6 | describe "parse/1" do 7 | test "can parse an Enhanced JSON file" do 8 | trip = %{ 9 | "trip_id" => "CR-Weekday-Spring-17-205", 10 | "start_date" => "2017-08-09", 11 | "schedule_relationship" => "SCHEDULED", 12 | "route_id" => "CR-Haverhill", 13 | "direction_id" => 0, 14 | "revenue" => true, 15 | "last_trip" => false 16 | } 17 | 18 | update = %{ 19 | "stop_id" => "place-north", 20 | "stop_sequence" => 6, 21 | "arrival" => %{ 22 | "time" => 1_502_290_000 23 | }, 24 | "departure" => %{ 25 | "time" => 1_502_290_500, 26 | "uncertainty" => 60 27 | } 28 | } 29 | 30 | body = 31 | Jason.encode!(%{ 32 | entity: [ 33 | %{ 34 | trip_update: %{ 35 | trip: trip, 36 | stop_time_update: [update] 37 | } 38 | } 39 | ] 40 | }) 41 | 42 | assert [%Model.Prediction{}] = parse(body) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/model/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Model.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :model, 6 | aliases: aliases(), 7 | build_embedded: Mix.env == :prod, 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps: deps(), 11 | deps_path: "../../deps", 12 | elixir: "~> 1.2", 13 | lockfile: "../../mix.lock", 14 | start_permanent: Mix.env == :prod, 15 | test_coverage: [tool: LcovEx], 16 | version: "0.0.1"] 17 | end 18 | 19 | # Configuration for the OTP application 20 | # 21 | # Type "mix help compile.app" for more information 22 | def application do 23 | [extra_applications: [:logger]] 24 | end 25 | 26 | defp aliases do 27 | [compile: ["compile --warnings-as-errors"]] 28 | end 29 | 30 | # Dependencies can be Hex packages: 31 | # 32 | # {:mydep, "~> 0.3.0"} 33 | # 34 | # Or git/path repositories: 35 | # 36 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 37 | # 38 | # To depend on another app inside the umbrella: 39 | # 40 | # {:myapp, in_umbrella: true} 41 | # 42 | # Type "mix help deps" for more examples and options 43 | defp deps do 44 | [{:timex, "~> 3.7"}] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/deadline.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.Deadline do 2 | @moduledoc """ 3 | Support for giving requests a time deadline, and allowing the request to end early if needed. 4 | """ 5 | @behaviour Plug 6 | import Plug.Conn 7 | 8 | @impl Plug 9 | def init(_) do 10 | [] 11 | end 12 | 13 | @impl Plug 14 | def call(conn, _) do 15 | put_private(conn, :api_web_deadline_start, current_time()) 16 | end 17 | 18 | @spec set(Plug.Conn.t(), integer) :: Plug.Conn.t() 19 | def set(%{private: %{api_web_deadline_start: start_time}} = conn, budget) do 20 | deadline = start_time + budget 21 | put_private(conn, :api_web_deadline, deadline) 22 | end 23 | 24 | @spec check!(Plug.Conn.t()) :: :ok | no_return 25 | def check!(%Plug.Conn{private: %{api_web_deadline: deadline}} = conn) do 26 | if current_time() > deadline do 27 | raise __MODULE__.Error, conn: conn 28 | else 29 | :ok 30 | end 31 | end 32 | 33 | def check!(%Plug.Conn{}) do 34 | # no deadline set 35 | :ok 36 | end 37 | 38 | defp current_time do 39 | System.monotonic_time(:millisecond) 40 | end 41 | 42 | defmodule Error do 43 | defexception plug_status: 503, message: "deadline exceeded", conn: nil 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/state_mediator/lib/state_mediator/mqtt_mediator/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule StateMediator.MqttMediator.Handler do 2 | @moduledoc """ 3 | EmqttFailover.ConnectionHandler implementation which sends the data to the provided state module. 4 | """ 5 | require Logger 6 | 7 | use EmqttFailover.ConnectionHandler 8 | 9 | @enforce_keys [ 10 | :module, 11 | :topic, 12 | :timeout 13 | ] 14 | defstruct @enforce_keys 15 | 16 | @impl EmqttFailover.ConnectionHandler 17 | def init(opts) do 18 | state = struct!(__MODULE__, opts) 19 | {:ok, state} 20 | end 21 | 22 | @impl EmqttFailover.ConnectionHandler 23 | def handle_connected(state) do 24 | Logger.info("StateMediator.MqttMediator subscribed topic=#{state.topic}") 25 | {:ok, [state.topic], state} 26 | end 27 | 28 | @impl EmqttFailover.ConnectionHandler 29 | def handle_message(message, state) do 30 | debug_time("#{state.module} new state", fn -> 31 | state.module.new_state(message.payload, state.timeout) 32 | end) 33 | 34 | {:ok, state} 35 | end 36 | 37 | defp debug_time(description, func) do 38 | State.Logger.debug_time(func, fn milliseconds -> 39 | "StateMediator.MqttMediator #{description} took #{milliseconds}ms" 40 | end) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: mix 9 | directory: "/apps/api_accounts" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | - package-ecosystem: mix 14 | directory: "/apps/api_web" 15 | schedule: 16 | interval: daily 17 | open-pull-requests-limit: 10 18 | - package-ecosystem: mix 19 | directory: "/apps/events" 20 | schedule: 21 | interval: daily 22 | open-pull-requests-limit: 10 23 | - package-ecosystem: mix 24 | directory: "/apps/fetch" 25 | schedule: 26 | interval: daily 27 | open-pull-requests-limit: 10 28 | - package-ecosystem: mix 29 | directory: "/apps/health" 30 | schedule: 31 | interval: daily 32 | open-pull-requests-limit: 10 33 | - package-ecosystem: mix 34 | directory: "/apps/model" 35 | schedule: 36 | interval: daily 37 | open-pull-requests-limit: 10 38 | - package-ecosystem: mix 39 | directory: "/apps/state" 40 | schedule: 41 | interval: daily 42 | open-pull-requests-limit: 10 43 | - package-ecosystem: mix 44 | directory: "/apps/state_mediator" 45 | schedule: 46 | interval: daily 47 | open-pull-requests-limit: 10 48 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/views/vehicle_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.VehicleViewTest do 2 | use ApiWeb.ConnCase 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | alias Model.Vehicle 8 | 9 | @vehicle %Vehicle{ 10 | id: "vehicle", 11 | revenue: :REVENUE 12 | } 13 | 14 | setup %{conn: conn} do 15 | conn = Phoenix.Controller.put_view(conn, ApiWeb.VehicleView) 16 | {:ok, %{conn: conn}} 17 | end 18 | 19 | test "render returns JSONAPI", %{conn: conn} do 20 | rendered = render(ApiWeb.VehicleView, "index.json-api", data: @vehicle, conn: conn) 21 | assert rendered["data"]["type"] == "vehicle" 22 | assert rendered["data"]["id"] == "vehicle" 23 | 24 | assert rendered["data"]["attributes"] == %{ 25 | "direction_id" => nil, 26 | "revenue" => "REVENUE", 27 | "bearing" => nil, 28 | "carriages" => [], 29 | "current_status" => nil, 30 | "current_stop_sequence" => nil, 31 | "label" => nil, 32 | "latitude" => nil, 33 | "longitude" => nil, 34 | "occupancy_status" => nil, 35 | "speed" => nil, 36 | "updated_at" => nil 37 | } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/controller_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ControllerHelpers do 2 | @moduledoc """ 3 | Simple helpers for multiple controllers. 4 | """ 5 | alias ApiWeb.LegacyStops 6 | 7 | import Plug.Conn 8 | 9 | @doc "Grab the ID from a struct/map" 10 | @spec id(%{id: any}) :: any 11 | def id(%{id: value}), do: value 12 | 13 | @doc """ 14 | Returns the current service date for the connection. If one isn't present, we look it up and store it. 15 | """ 16 | def conn_service_date(conn) do 17 | case conn.private do 18 | %{api_web_service_date: date} -> 19 | {conn, date} 20 | 21 | _ -> 22 | date = Parse.Time.service_date() 23 | conn = put_private(conn, :api_web_service_date, date) 24 | {conn, date} 25 | end 26 | end 27 | 28 | @doc """ 29 | Given a map containing a set of filters, one of which expresses a list of stop IDs, expands the 30 | list using `LegacyStops` with the given API version. 31 | """ 32 | @spec expand_stops_filter(map, any, String.t()) :: map 33 | def expand_stops_filter(filters, stops_key, api_version) do 34 | case Map.has_key?(filters, stops_key) do 35 | true -> Map.update!(filters, stops_key, &LegacyStops.expand(&1, api_version)) 36 | false -> filters 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/sentry_event_filter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.SentryEventFilterTest do 2 | use ApiWeb.ConnCase, async: true 3 | 4 | describe "filter_event/1" do 5 | setup do 6 | Sentry.Test.start_collecting_sentry_reports() 7 | end 8 | 9 | test "filters out `NoRouteError`s rescued by plugs", %{conn: conn} do 10 | conn = get(conn, "/a_nonexistent_route") 11 | assert response(conn, 404) 12 | 13 | assert [] = Sentry.Test.pop_sentry_reports() 14 | end 15 | 16 | test "filters out `NoRouteError`s surfaced as messages via crashes" do 17 | Sentry.capture_message("Something something {{{%Phoenix.Router.NoRouteError}}}") 18 | assert [] = Sentry.Test.pop_sentry_reports() 19 | 20 | Sentry.capture_message("Something something {{{%SomeOtherError}}}") 21 | assert [%Sentry.Event{} = err] = Sentry.Test.pop_sentry_reports() 22 | assert err.message.formatted =~ "SomeOtherError" 23 | end 24 | 25 | test "does not filter out other exceptions" do 26 | err = RuntimeError.exception("An error other than NoRouteError") 27 | 28 | Sentry.capture_exception(err) 29 | 30 | assert [%Sentry.Event{} = event] = Sentry.Test.pop_sentry_reports() 31 | assert ^err = event.original_exception 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/events/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Events.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :events, 6 | aliases: aliases(), 7 | build_embedded: Mix.env == :prod, 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps: deps(), 11 | deps_path: "../../deps", 12 | elixir: "~> 1.2", 13 | lockfile: "../../mix.lock", 14 | start_permanent: Mix.env == :prod, 15 | test_coverage: [tool: LcovEx], 16 | version: "0.0.1"] 17 | end 18 | 19 | # Configuration for the OTP application 20 | # 21 | # Type "mix help compile.app" for more information 22 | def application do 23 | [ 24 | mod: {Events.Application, []}, 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | defp aliases do 30 | [compile: ["compile --warnings-as-errors"]] 31 | end 32 | 33 | # Dependencies can be Hex packages: 34 | # 35 | # {:mydep, "~> 0.3.0"} 36 | # 37 | # Or git/path repositories: 38 | # 39 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 40 | # 41 | # To depend on another app inside the umbrella: 42 | # 43 | # {:myapp, in_umbrella: true} 44 | # 45 | # Type "mix help deps" for more examples and options 46 | defp deps do 47 | [] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/simple.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Simple do 2 | @moduledoc """ 3 | Simple CSV parser that only needs to define `parse_row(row)`. 4 | 5 | defmodule Parse.Thing do 6 | use Parse.Simple 7 | 8 | def parse_row(row) do 9 | ... 10 | end 11 | end 12 | 13 | """ 14 | 15 | alias NimbleCSV.RFC4180, as: CSV 16 | 17 | @callback parse_row(%{String.t() => String.t()}) :: any 18 | 19 | defmacro __using__([]) do 20 | quote location: :keep do 21 | import :binary, only: [copy: 1] 22 | @behaviour Parse 23 | @behaviour unquote(__MODULE__) 24 | 25 | @spec parse(String.t()) :: [any] 26 | def parse(blob) do 27 | unquote(__MODULE__).parse(blob, &parse_row/1) 28 | end 29 | end 30 | end 31 | 32 | @spec parse(String.t(), (map -> any)) :: [map] 33 | def parse(blob, row_callback) 34 | 35 | def parse("", _) do 36 | [] 37 | end 38 | 39 | def parse(blob, row_callback) do 40 | blob 41 | |> String.trim() 42 | |> CSV.parse_string(skip_headers: false) 43 | |> Stream.transform(nil, fn 44 | headers, nil -> {[], headers} 45 | row, headers -> {[headers |> Enum.zip(row) |> Map.new()], headers} 46 | end) 47 | |> Enum.to_list() 48 | |> Enum.map(row_callback) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/rate_limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RateLimiter do 2 | @moduledoc """ 3 | Rate limits a user based on their API key or by their IP address if no 4 | API key is provided. 5 | """ 6 | 7 | import Plug.Conn 8 | import Phoenix.Controller, only: [render: 3, put_view: 2] 9 | 10 | def init(opts), do: opts 11 | 12 | def call(%{assigns: assigns, request_path: request_path} = conn, _) do 13 | case ApiWeb.RateLimiter.log_request(assigns.api_user, request_path) do 14 | :ok -> 15 | conn 16 | 17 | {:ok, {limit, remaining, reset_ms}} -> 18 | conn 19 | |> put_rate_limit_headers(limit, remaining, reset_ms) 20 | 21 | {:rate_limited, {limit, remaining, reset_ms}} -> 22 | conn 23 | |> put_rate_limit_headers(limit, remaining, reset_ms) 24 | |> put_status(429) 25 | |> put_view(ApiWeb.ErrorView) 26 | |> render("429.json-api", []) 27 | |> halt() 28 | end 29 | end 30 | 31 | defp put_rate_limit_headers(conn, limit, remaining, reset_ms) do 32 | reset_seconds = div(reset_ms, 1_000) 33 | 34 | conn 35 | |> put_resp_header("x-ratelimit-limit", "#{limit}") 36 | |> put_resp_header("x-ratelimit-remaining", "#{remaining}") 37 | |> put_resp_header("x-ratelimit-reset", "#{reset_seconds}") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/plugs/require_admin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.RequireAdminTest do 2 | use ApiWeb.ConnCase, async: true 3 | 4 | setup %{conn: conn} do 5 | conn = 6 | conn 7 | |> conn_with_session() 8 | |> bypass_through(ApiWeb.Router, [:browser, :admin]) 9 | 10 | {:ok, conn: conn} 11 | end 12 | 13 | test "opts" do 14 | assert ApiWeb.Plugs.RequireAdmin.init([]) == [] 15 | end 16 | 17 | describe ":require_admin plug" do 18 | test "gives 404 with no authenicated user", %{conn: conn} do 19 | conn = get(conn, "/") 20 | assert conn.status == 404 21 | assert html_response(conn, 404) =~ "not found" 22 | end 23 | 24 | test "gives 404 for user without administrator role", %{conn: conn} do 25 | conn = 26 | conn 27 | |> user_with_role(nil) 28 | |> get("/") 29 | 30 | assert html_response(conn, 404) =~ "not found" 31 | end 32 | 33 | test "allows user with administrator role to proceed", %{conn: conn} do 34 | conn = 35 | conn 36 | |> user_with_role("administrator") 37 | |> get("/") 38 | 39 | refute conn.status 40 | end 41 | end 42 | 43 | defp user_with_role(conn, role) do 44 | Plug.Conn.assign(conn, :user, %ApiAccounts.User{role: role, totp_enabled: true}) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /apps/health/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Health.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :health, 6 | aliases: aliases(), 7 | build_embedded: Mix.env == :prod, 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps: deps(), 11 | deps_path: "../../deps", 12 | elixir: "~> 1.3", 13 | lockfile: "../../mix.lock", 14 | start_permanent: Mix.env == :prod, 15 | test_coverage: [tool: LcovEx], 16 | version: "0.1.0"] 17 | end 18 | 19 | # Configuration for the OTP application 20 | # 21 | # Type "mix help compile.app" for more information 22 | def application do 23 | [extra_applications: [:logger], 24 | mod: {Health, []}] 25 | end 26 | 27 | defp aliases do 28 | [compile: ["compile --warnings-as-errors"]] 29 | end 30 | 31 | # Dependencies can be Hex packages: 32 | # 33 | # {:mydep, "~> 0.3.0"} 34 | # 35 | # Or git/path repositories: 36 | # 37 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 38 | # 39 | # To depend on another app inside the umbrella: 40 | # 41 | # {:myapp, in_umbrella: true} 42 | # 43 | # Type "mix help deps" for more examples and options 44 | defp deps do 45 | [{:events, in_umbrella: true}, 46 | {:state, in_umbrella: true}] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ELIXIR_VERSION=1.17.3 2 | ARG ERLANG_VERSION=27.2 3 | ARG ALPINE_VERSION=3.21.0 4 | 5 | FROM hexpm/elixir:${ELIXIR_VERSION}-erlang-${ERLANG_VERSION}-alpine-${ALPINE_VERSION} as builder 6 | 7 | WORKDIR /root 8 | 9 | # Install Hex+Rebar 10 | RUN mix local.hex --force && \ 11 | mix local.rebar --force 12 | 13 | RUN apk add --update git make build-base erlang-dev 14 | 15 | ENV MIX_ENV=prod 16 | 17 | ADD apps apps 18 | ADD config config 19 | ADD mix.* /root/ 20 | 21 | RUN mix do deps.get --only prod, phx.swagger.generate, compile, phx.digest, sentry.package_source_code 22 | RUN mix eval "Application.ensure_all_started(:tzdata); Tzdata.DataBuilder.load_and_save_table()" 23 | 24 | ADD rel/ rel/ 25 | 26 | RUN mix release 27 | 28 | # The one the elixir image was built with 29 | FROM alpine:${ALPINE_VERSION} 30 | 31 | RUN apk add --no-cache libssl3 dumb-init libstdc++ libgcc ncurses-libs && \ 32 | mkdir /work /api && \ 33 | adduser -D api && chown api /work 34 | 35 | COPY --from=builder /root/_build/prod/rel/api_web /api 36 | 37 | # Set exposed ports 38 | EXPOSE 4000 39 | ENV PORT=4000 MIX_ENV=prod TERM=xterm LANG=C.UTF-8 \ 40 | ERL_CRASH_DUMP_SECONDS=0 RELEASE_TMP=/work 41 | 42 | USER api 43 | WORKDIR /work 44 | 45 | ENTRYPOINT ["/usr/bin/dumb-init", "--"] 46 | 47 | HEALTHCHECK CMD ["/api/bin/api_web", "rpc", "1 + 1"] 48 | CMD ["/api/bin/api_web", "start"] 49 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Endpoint do 2 | use Sentry.PlugCapture 3 | use Phoenix.Endpoint, otp_app: :api_web 4 | 5 | # You should set gzip to true if you are running phoenix.digest 6 | # when deploying your static files in production. 7 | 8 | # Code reloading can be explicitly enabled under the 9 | # :code_reloader configuration of your endpoint. 10 | if code_reloading? do 11 | plug(Phoenix.CodeReloader) 12 | end 13 | 14 | plug(Plug.Static, at: "/", from: :api_web, gzip: false, only: ~w(js css images favicon.ico)) 15 | 16 | plug(Plug.RequestId) 17 | plug(Logster.Plugs.Logger) 18 | 19 | plug( 20 | Plug.Parsers, 21 | parsers: [:urlencoded], 22 | pass: ["application/json", "application/vnd.api+json", "application/x-www-form-urlencoded"] 23 | ) 24 | 25 | # Sentry must be invoked after Plug.Parsers: 26 | plug(Sentry.PlugContext) 27 | 28 | plug(Plug.MethodOverride) 29 | plug(Plug.Head) 30 | plug(ApiWeb.Plugs.ClearMetadata, ~w(api_key ip api_version concurrent records)a) 31 | plug(ApiWeb.Plugs.Deadline) 32 | plug(ApiWeb.Plugs.ExperimentalFeatures) 33 | # CORS needs to be before the router, and Authenticate needs to be before CORS 34 | plug(ApiWeb.Plugs.Authenticate) 35 | plug(ApiWeb.Plugs.CORS) 36 | plug(ApiWeb.Plugs.RequestTrack, name: ApiWeb.RequestTrack) 37 | plug(ApiWeb.Router) 38 | end 39 | -------------------------------------------------------------------------------- /apps/state/bench/schedule_bench.exs: -------------------------------------------------------------------------------- 1 | defmodule State.ScheduleBench do 2 | use Benchfella 3 | 4 | @schedule %Model.Schedule{ 5 | trip_id: "trip", 6 | stop_id: "stop", 7 | position: :first} 8 | 9 | def setup_all do 10 | Application.ensure_all_started(:events) 11 | State.Schedule.start_link 12 | @schedule 13 | |> expand_schedule(30_000) 14 | |> State.Schedule.new_state 15 | 16 | {:ok, pid} 17 | end 18 | 19 | def teardown_all(pid) do 20 | State.Schedule.stop 21 | end 22 | 23 | defp expand_schedule(schedule, count) do 24 | [schedule|do_expand_schedule(schedule, count)] 25 | end 26 | defp do_expand_schedule(_, 0), do: [] 27 | defp do_expand_schedule(%{trip_id: trip_id} = schedule, count) do 28 | new_schedule = case rem(count, 3) do 29 | 0 -> %{schedule | trip_id: "#{trip_id}_#{count}"} 30 | 1 -> schedule 31 | 2 -> %{schedule | stop_id: "#{trip_id}_#{count}"} 32 | end 33 | [new_schedule|do_expand_schedule(schedule, count - 1)] 34 | end 35 | 36 | bench "by_trip_id" do 37 | "trip" 38 | |> State.Schedule.by_trip_id 39 | |> Enum.filter(&(&1.position == :first)) 40 | end 41 | 42 | bench "match" do 43 | #[@schedule] = 44 | State.Schedule.match(%{trip_id: "trip", position: :first}, :trip_id) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /apps/parse/lib/parse/calendar.ex: -------------------------------------------------------------------------------- 1 | defmodule Parse.Calendar do 2 | @moduledoc """ 3 | 4 | Parse a calendar.txt 5 | (https://developers.google.com/transit/gtfs/reference#calendartxt) file 6 | into a simple struct. 7 | 8 | """ 9 | @behaviour Parse 10 | defstruct [:service_id, :days, :start_date, :end_date] 11 | use Timex 12 | 13 | def parse(blob) do 14 | blob 15 | |> BinaryLineSplit.stream!() 16 | |> SimpleCSV.decode() 17 | |> Enum.map(&parse_row/1) 18 | end 19 | 20 | defp parse_row(row) do 21 | %__MODULE__{ 22 | service_id: :binary.copy(row["service_id"]), 23 | start_date: parse_date(row["start_date"]), 24 | end_date: parse_date(row["end_date"]), 25 | days: parse_days(row) 26 | } 27 | end 28 | 29 | defp parse_date(date) do 30 | date 31 | |> Timex.parse!("{YYYY}{0M}{0D}") 32 | |> NaiveDateTime.to_date() 33 | end 34 | 35 | defp parse_days(row) do 36 | [] 37 | |> add_day(row["sunday"], 7) 38 | |> add_day(row["saturday"], 6) 39 | |> add_day(row["friday"], 5) 40 | |> add_day(row["thursday"], 4) 41 | |> add_day(row["wednesday"], 3) 42 | |> add_day(row["tuesday"], 2) 43 | |> add_day(row["monday"], 1) 44 | end 45 | 46 | defp add_day(result, "1", value) do 47 | [value | result] 48 | end 49 | 50 | defp add_day(result, _, _) do 51 | result 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /apps/parse/test/parse/polyline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.PolylineTest do 2 | use ExUnit.Case, async: true 3 | import Parse.Polyline 4 | 5 | @blob """ 6 | "shape_id","shape_pt_lat","shape_pt_lon","shape_pt_sequence","shape_dist_traveled" 7 | "cf00001",41.660225,-70.276583,1,"" 8 | "cf00001",41.683585,-70.258956,2,"" 9 | "cf00001",41.692367,-70.256982,3,"" 10 | "cf00001",41.699288,-70.259557,4,"" 11 | "cf00001",41.700506,-70.262682,5,"" 12 | "cf00001",41.700554,-70.265043,6,"" 13 | "cf00001",41.696837,-70.279333,7,"" 14 | "cf00001",41.697878,-70.300469,8,"" 15 | "cf00001",41.701739,-70.319245,9,"" 16 | "cf00001",41.700282,-70.343428,10,"" 17 | "cf00001",41.701403,-70.354543,11,"" 18 | "cf00001",41.702508,-70.360386,12,"" 19 | "cf00001",41.708372,-70.377788,13,"" 20 | "cf00001",41.731347,-70.433868,14,"" 21 | "cf00001",41.744468,-70.455551,15,"" 22 | "cf00001",41.746093,-70.464638,16,"" 23 | "cf00001",41.751929,-70.477781,17,"" 24 | "cf00001",41.75517,-70.48748,18,"" 25 | """ 26 | 27 | describe "parse/1" do 28 | test "test returns a list of shapes" do 29 | parsed = parse(@blob) 30 | 31 | assert [ 32 | %Parse.Polyline{id: "cf00001", polyline: polyline} 33 | ] = parsed 34 | 35 | # slight loss of precision 36 | assert [{-70.27658, 41.66022} | _] = Polyline.decode(polyline) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/parse/test/parse/routes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.RoutesTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Parse.Routes 5 | alias Model.Route 6 | 7 | describe "parse_row/1" do 8 | test "parses a route CSV map into a %Route{}" do 9 | row = %{ 10 | "route_id" => "CapeFlyer", 11 | "agency_id" => "3", 12 | "route_short_name" => "", 13 | "route_long_name" => "CapeFLYER", 14 | "route_desc" => "", 15 | "route_fare_class" => "Rapid Transit", 16 | "route_type" => "2", 17 | "route_url" => "http://capeflyer.com/", 18 | "route_color" => "006595", 19 | "route_text_color" => "FFFFFF", 20 | "route_sort_order" => "100", 21 | "line_id" => "line-Orange", 22 | "listed_route" => "1" 23 | } 24 | 25 | expected = %Route{ 26 | id: "CapeFlyer", 27 | agency_id: "3", 28 | short_name: "", 29 | long_name: "CapeFLYER", 30 | description: "", 31 | fare_class: "Rapid Transit", 32 | type: 2, 33 | color: "006595", 34 | text_color: "FFFFFF", 35 | sort_order: 100, 36 | line_id: "line-Orange", 37 | listed_route: false, 38 | direction_destinations: [nil, nil], 39 | direction_names: [nil, nil] 40 | } 41 | 42 | assert parse_row(row) == expected 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/controllers/status_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.StatusControllerTest do 2 | use ApiWeb.ConnCase 3 | 4 | test "returns service metadata", %{conn: conn} do 5 | State.Feed.new_state(%Model.Feed{ 6 | version: "TEST", 7 | start_date: ~D[2019-01-01], 8 | end_date: ~D[2019-02-01] 9 | }) 10 | 11 | conn = get(conn, status_path(conn, :index)) 12 | assert json = json_response(conn, 200) 13 | assert_attribute_key(json, "feed") 14 | assert_feed_key(json, "version") 15 | assert_feed_key(json, "start_date") 16 | assert_feed_key(json, "end_date") 17 | assert_attribute_key(json, "alert") 18 | assert_attribute_key(json, "facility") 19 | assert_attribute_key(json, "prediction") 20 | assert_attribute_key(json, "route") 21 | assert_attribute_key(json, "route_pattern") 22 | assert_attribute_key(json, "schedule") 23 | assert_attribute_key(json, "service") 24 | assert_attribute_key(json, "shape") 25 | assert_attribute_key(json, "stop") 26 | assert_attribute_key(json, "trip") 27 | assert_attribute_key(json, "vehicle") 28 | end 29 | 30 | def assert_attribute_key(json, attribute_key) do 31 | assert get_in(json, ["data", "attributes", attribute_key]) 32 | end 33 | 34 | def assert_feed_key(json, feed_key) do 35 | assert get_in(json, ["data", "attributes", "feed", feed_key]) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.ErrorViewTest do 2 | use ApiWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "render 400 (invalid request)" do 8 | rendered = render(ApiWeb.ErrorView, "400.json", %{error: :invalid}) 9 | only_route_type = render(ApiWeb.ErrorView, "400.json", %{error: :only_route_type}) 10 | distance_params = render(ApiWeb.ErrorView, "400.json", %{error: :distance_params}) 11 | assert [%{code: :bad_request}] = rendered["errors"] 12 | assert [%{code: :bad_request}] = only_route_type["errors"] 13 | assert [%{code: :bad_request}] = distance_params["errors"] 14 | end 15 | 16 | test "renders 404.json" do 17 | rendered = render(ApiWeb.ErrorView, "404.json", []) 18 | assert [%{code: :not_found}] = rendered["errors"] 19 | end 20 | 21 | test "render 406.json" do 22 | rendered = render(ApiWeb.ErrorView, "406.json", []) 23 | assert [%{code: :not_acceptable}] = rendered["errors"] 24 | end 25 | 26 | test "render 500.json" do 27 | rendered = render(ApiWeb.ErrorView, "500.json", []) 28 | assert [%{code: :internal_error}] = rendered["errors"] 29 | end 30 | 31 | test "render any other" do 32 | rendered = render(ApiWeb.ErrorView, "505.json", []) 33 | assert [%{code: :internal_error}] = rendered["errors"] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/api_accounts/lib/decoders.ex: -------------------------------------------------------------------------------- 1 | defimpl ExAws.Dynamo.Decodable, for: ApiAccounts.User do 2 | def decode(%ApiAccounts.User{join_date: nil} = user), do: user 3 | 4 | def decode(%ApiAccounts.User{join_date: join_date, totp_since: totp_since} = user) do 5 | join_date = Decoders.datetime(join_date) 6 | totp_since = Decoders.datetime(totp_since) 7 | totp_binary = if user.totp_secret, do: Base.decode32!(user.totp_secret) 8 | 9 | %ApiAccounts.User{ 10 | user 11 | | join_date: join_date, 12 | totp_since: totp_since, 13 | totp_secret_bin: totp_binary 14 | } 15 | end 16 | end 17 | 18 | defimpl ExAws.Dynamo.Decodable, for: ApiAccounts.Key do 19 | def decode(%ApiAccounts.Key{} = key) do 20 | created = 21 | if key.created do 22 | Decoders.datetime(key.created) 23 | end 24 | 25 | requested_date = 26 | if key.requested_date do 27 | Decoders.datetime(key.requested_date) 28 | end 29 | 30 | %ApiAccounts.Key{key | created: created, requested_date: requested_date} 31 | end 32 | end 33 | 34 | defmodule Decoders do 35 | @moduledoc false 36 | def datetime(nil), do: nil 37 | 38 | def datetime(datetime_string) do 39 | if String.ends_with?(datetime_string, "Z") do 40 | {:ok, datetime, _} = DateTime.from_iso8601(datetime_string) 41 | datetime 42 | else 43 | NaiveDateTime.from_iso8601!(datetime_string) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /apps/model/lib/recordable.ex: -------------------------------------------------------------------------------- 1 | defmodule Recordable do 2 | @moduledoc """ 3 | Converts a `struct` to a record that can be stores in ETS and mnesia and back out again. 4 | """ 5 | 6 | defmacro recordable(opts) do 7 | keys_or_kvs = Macro.expand(opts, __CALLER__) 8 | 9 | keys = 10 | for key_or_kv <- keys_or_kvs do 11 | case key_or_kv do 12 | {key, _} -> key 13 | key -> key 14 | end 15 | end 16 | 17 | vals = Enum.map(keys, &{&1, [], nil}) 18 | pairs = Enum.zip(keys, vals) 19 | 20 | # create a pairs object with each value being the filler variable 21 | fill_pairs = Enum.zip(keys, Stream.cycle([:_])) 22 | 23 | quote do 24 | require Record 25 | 26 | defstruct unquote(keys_or_kvs) 27 | Record.defrecord(__MODULE__, [unquote_splicing(keys)]) 28 | 29 | def to_record(%__MODULE__{unquote_splicing(pairs)}) do 30 | {__MODULE__, unquote_splicing(vals)} 31 | end 32 | 33 | def from_record({__MODULE__, unquote_splicing(vals)}) do 34 | %__MODULE__{unquote_splicing(pairs)} 35 | end 36 | 37 | def fields, do: unquote(keys) 38 | 39 | def filled(_) do 40 | %__MODULE__{unquote_splicing(fill_pairs)} 41 | end 42 | end 43 | end 44 | 45 | defmacro __using__(keys) do 46 | quote do 47 | require unquote(__MODULE__) 48 | unquote(__MODULE__).recordable(unquote(keys)) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /apps/state/test/state/alert/active_period_test.exs: -------------------------------------------------------------------------------- 1 | defmodule State.Alert.ActivePeriodTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | import State.Alert.ActivePeriod 5 | alias Model.Alert 6 | 7 | @table __MODULE__ 8 | 9 | @alerts [ 10 | %Alert{ 11 | id: "1" 12 | # no active period, matches everything 13 | }, 14 | %Alert{ 15 | id: "2", 16 | active_period: [ 17 | {nil, DateTime.from_unix!(1000)}, 18 | {DateTime.from_unix!(2000), DateTime.from_unix!(3000)}, 19 | {DateTime.from_unix!(4000), nil} 20 | ] 21 | } 22 | ] 23 | 24 | setup do 25 | new(@table) 26 | update(@table, @alerts) 27 | :ok 28 | end 29 | 30 | describe "update/2" do 31 | test "ignores empty updates" do 32 | update(@table, []) 33 | assert size(@table) == 4 34 | end 35 | end 36 | 37 | describe "filter/3" do 38 | test "filters a list to just those that match the given ID" do 39 | for {unix, expected_ids} <- [ 40 | {0, ~w(1 2)}, 41 | {1000, ~w(1)}, 42 | {2500, ~w(1 2)}, 43 | {3500, ~w(1)}, 44 | {5000, ~w(1 2)} 45 | ] do 46 | dt = DateTime.from_unix!(unix) 47 | 48 | actual_ids = 49 | @table 50 | |> filter(~w(1 2 3), dt) 51 | |> Enum.sort() 52 | 53 | assert actual_ids == expected_ids 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/views/status_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.StatusView do 2 | use ApiWeb.Web, :api_view 3 | 4 | attributes([ 5 | :feed, 6 | :alert, 7 | :facility, 8 | :prediction, 9 | :route, 10 | :route_pattern, 11 | :schedule, 12 | :service, 13 | :shape, 14 | :stop, 15 | :trip, 16 | :vehicle 17 | ]) 18 | 19 | def feed(data, _), do: data.feed 20 | 21 | def alert(data, _) do 22 | %{last_updated: data.timestamps.alert} 23 | end 24 | 25 | def facility(data, _) do 26 | %{last_updated: data.timestamps.facility} 27 | end 28 | 29 | def prediction(data, _) do 30 | %{last_updated: data.timestamps.prediction} 31 | end 32 | 33 | def route(data, _) do 34 | %{last_updated: data.timestamps.route} 35 | end 36 | 37 | def route_pattern(data, _) do 38 | %{last_updated: data.timestamps.route_pattern} 39 | end 40 | 41 | def schedule(data, _) do 42 | %{last_updated: data.timestamps.schedule} 43 | end 44 | 45 | def service(data, _) do 46 | %{last_updated: data.timestamps.service} 47 | end 48 | 49 | def shape(data, _) do 50 | %{last_updated: data.timestamps.shape} 51 | end 52 | 53 | def stop(data, _) do 54 | %{last_updated: data.timestamps.stop} 55 | end 56 | 57 | def trip(data, _) do 58 | %{last_updated: data.timestamps.trip} 59 | end 60 | 61 | def vehicle(data, _) do 62 | %{last_updated: data.timestamps.vehicle} 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/controllers/mfa_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.MfaControllerTest do 2 | @moduledoc false 3 | use ApiWeb.ConnCase, async: false 4 | 5 | alias ApiWeb.Fixtures 6 | 7 | setup %{conn: conn} do 8 | ApiAccounts.Dynamo.create_table(ApiAccounts.User) 9 | on_exit(fn -> ApiAccounts.Dynamo.delete_all_tables() end) 10 | {:ok, conn: conn} 11 | end 12 | 13 | test "2fa redirects user on success", %{conn: conn} do 14 | user = Fixtures.fixture(:totp_user) 15 | 16 | conn = 17 | conn 18 | |> conn_with_session() 19 | |> put_session(:inc_user_id, user.id) 20 | |> put_session(:destination, portal_path(conn, :index)) 21 | 22 | conn = 23 | post( 24 | form_header(conn), 25 | mfa_path(conn, :create), 26 | user: %{totp_code: NimbleTOTP.verification_code(user.totp_secret_bin)} 27 | ) 28 | 29 | assert redirected_to(conn) == portal_path(conn, :index) 30 | end 31 | 32 | test "2fa does not accept invalid codes", %{conn: conn} do 33 | user = Fixtures.fixture(:totp_user) 34 | 35 | conn = conn |> conn_with_session() |> put_session(:inc_user_id, user.id) 36 | 37 | conn = 38 | post( 39 | form_header(conn), 40 | mfa_path(conn, :create), 41 | user: %{totp_code: "1234"} 42 | ) 43 | 44 | assert html_response(conn, 200) =~ "TOTP" 45 | assert Phoenix.Flash.get(conn.assigns.flash, :error) != nil 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /apps/health/lib/health/checkers/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Health.Checkers.State do 2 | @moduledoc """ 3 | Health check which makes sure various State modules have data. 4 | """ 5 | use Events.Server 6 | 7 | @apps [ 8 | State.Schedule, 9 | State.Alert, 10 | State.ServiceByDate, 11 | State.StopsOnRoute, 12 | State.RoutesPatternsAtStop, 13 | State.Shape 14 | ] 15 | 16 | def start_link(_opts \\ []) do 17 | GenServer.start_link(__MODULE__, nil, name: __MODULE__) 18 | end 19 | 20 | def current do 21 | GenServer.call(__MODULE__, :current) 22 | end 23 | 24 | def healthy? do 25 | current() 26 | |> Enum.all?(fn {_, count} -> 27 | count > 0 28 | end) 29 | end 30 | 31 | @impl Events.Server 32 | def handle_event({:new_state, app}, count, _, state) do 33 | new_state = 34 | state 35 | |> Keyword.put(app_name(app), count) 36 | 37 | {:noreply, new_state} 38 | end 39 | 40 | @impl GenServer 41 | def init(nil) do 42 | statuses = 43 | for app <- @apps do 44 | subscribe({:new_state, app}) 45 | {app_name(app), app.size()} 46 | end 47 | 48 | {:ok, statuses} 49 | end 50 | 51 | @impl GenServer 52 | def handle_call(:current, _from, state) do 53 | {:reply, state, state} 54 | end 55 | 56 | defp app_name(app) do 57 | app 58 | |> Module.split() 59 | |> List.last() 60 | |> String.downcase() 61 | |> String.to_atom() 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/templates/admin/layout/navigation.html.heex: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/controllers/portal/portal_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Controllers.Portal.PortalControllerTest do 2 | use ApiWeb.ConnCase 3 | 4 | describe "Test portal with keys" do 5 | setup :setup_key_requesting_user 6 | 7 | test "index loads", %{conn: conn} do 8 | conn = get(conn, portal_path(conn, :index)) 9 | assert html_response(conn, 200) =~ "Api Keys" 10 | end 11 | 12 | test "index displays default key limit per minute", %{ 13 | user: user, 14 | conn: conn 15 | } do 16 | {:ok, key} = ApiAccounts.create_key(user) 17 | {:ok, _} = ApiAccounts.update_key(key, %{approved: true}) 18 | conn = get(conn, portal_path(conn, :index)) 19 | max = ApiWeb.config(:rate_limiter, :max_registered_per_interval) 20 | interval = ApiWeb.config(:rate_limiter, :clear_interval) 21 | per_interval_minute = div(max, interval) * 60_000 22 | assert html_response(conn, 200) =~ "#{per_interval_minute}" 23 | end 24 | 25 | test "index displays dynamic key limit", %{user: user, conn: conn} do 26 | {:ok, key} = ApiAccounts.create_key(user) 27 | {:ok, _} = ApiAccounts.update_key(key, %{approved: true, daily_limit: 999_999_999_999}) 28 | conn = get(conn, portal_path(conn, :index)) 29 | assert html_response(conn, 200) =~ "694444200" 30 | end 31 | end 32 | 33 | test "landing", %{conn: conn} do 34 | conn = get(conn, portal_path(conn, :landing)) 35 | assert html_response(conn, 200) =~ "

MBTA V3 API Portal

" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/fetch/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Fetch.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :fetch, 7 | aliases: aliases(), 8 | build_embedded: Mix.env() == :prod, 9 | build_path: "../../_build", 10 | config_path: "../../config/config.exs", 11 | deps: deps(), 12 | deps_path: "../../deps", 13 | elixir: "~> 1.2", 14 | lockfile: "../../mix.lock", 15 | start_permanent: Mix.env() == :prod, 16 | test_coverage: [tool: LcovEx], 17 | version: "0.0.1" 18 | ] 19 | end 20 | 21 | # Configuration for the OTP application 22 | # 23 | # Type "mix help compile.app" for more information 24 | def application do 25 | [ 26 | mod: {Fetch.App, []} 27 | ] 28 | end 29 | 30 | defp aliases do 31 | [compile: ["compile --warnings-as-errors"]] 32 | end 33 | 34 | # Dependencies can be Hex packages: 35 | # 36 | # {:mydep, "~> 0.3.0"} 37 | # 38 | # Or git/path repositories: 39 | # 40 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 41 | # 42 | # To depend on another app inside the umbrella: 43 | # 44 | # {:myapp, in_umbrella: true} 45 | # 46 | # Type "mix help deps" for more examples and options 47 | defp deps do 48 | [ 49 | {:httpoison, "~> 2.0"}, 50 | {:events, in_umbrella: true}, 51 | {:model, in_umbrella: true}, 52 | {:timex, "~> 3.7"}, 53 | {:ex_aws, "~> 2.4"}, 54 | {:ex_aws_s3, "~> 2.4"}, 55 | {:lasso, "~> 0.1.1-pre", only: :test} 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/parse/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Parse.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :parse, 7 | aliases: aliases(), 8 | build_embedded: Mix.env() == :prod, 9 | build_path: "../../_build", 10 | config_path: "../../config/config.exs", 11 | deps: deps(), 12 | deps_path: "../../deps", 13 | elixir: "~> 1.2", 14 | lockfile: "../../mix.lock", 15 | start_permanent: Mix.env() == :prod, 16 | test_coverage: [tool: LcovEx], 17 | version: "0.0.1" 18 | ] 19 | end 20 | 21 | # Configuration for the OTP application 22 | # 23 | # Type "mix help compile.app" for more information 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | defp aliases do 31 | [compile: ["compile --warnings-as-errors"]] 32 | end 33 | 34 | # Dependencies can be Hex packages: 35 | # 36 | # {:mydep, "~> 0.3.0"} 37 | # 38 | # Or git/path repositories: 39 | # 40 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 41 | # 42 | # To depend on another app inside the umbrella: 43 | # 44 | # {:myapp, in_umbrella: true} 45 | # 46 | # Type "mix help deps" for more examples and options 47 | defp deps do 48 | [ 49 | {:nimble_csv, "~> 1.2"}, 50 | {:timex, "~> 3.7"}, 51 | {:jason, "~> 1.4"}, 52 | {:model, in_umbrella: true}, 53 | {:polyline, "~> 1.3"}, 54 | {:fast_local_datetime, "~> 1.0"}, 55 | {:nimble_parsec, "~> 1.2"} 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/api_web/lib/api_web/plugs/check_for_shutdown.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.Plugs.CheckForShutdown do 2 | @moduledoc """ 3 | Tells all requests to close with the "Connection: close" header when the system is shutting down. 4 | """ 5 | 6 | import Plug.Conn 7 | 8 | @behaviour Plug 9 | 10 | @impl Plug 11 | def init(_opts) do 12 | {:ok, true} 13 | end 14 | 15 | @impl Plug 16 | def call(conn, _) do 17 | if running?() do 18 | conn 19 | else 20 | put_resp_header(conn, "connection", "close") 21 | end 22 | end 23 | 24 | @compile inline: [running?: 0] 25 | 26 | @spec running?() :: boolean 27 | @doc """ 28 | Return a boolean indicating whether the system is still running. 29 | """ 30 | def running? do 31 | :persistent_term.get(__MODULE__, true) 32 | end 33 | 34 | @spec started() :: :ok 35 | @doc """ 36 | Mark the system as started. 37 | 38 | Not required, but improves the performance in the "is-running" case. 39 | 40 | We can't do this in `init/1`, because that might happen at compile-time instead of runtime. 41 | """ 42 | def started do 43 | :persistent_term.put(__MODULE__, true) 44 | 45 | :ok 46 | end 47 | 48 | @spec shutdown() :: :ok 49 | @doc """ 50 | Mark the system as shutting down, so that all connections are closed. 51 | """ 52 | def shutdown do 53 | :persistent_term.put(__MODULE__, false) 54 | 55 | :ok 56 | end 57 | 58 | # test-only function for re-setting the persistent_term state 59 | @doc false 60 | def reset do 61 | :persistent_term.erase(__MODULE__) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /apps/api_web/test/api_web/canary_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiWeb.CanaryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ApiWeb.Canary 5 | 6 | test "calls the provided function when terminated with reason :shutdown" do 7 | test_pid = self() 8 | {:ok, canary} = GenServer.start(Canary, fn -> send(test_pid, :notified) end) 9 | refute_receive :notified 10 | 11 | GenServer.stop(canary, :shutdown) 12 | assert_receive :notified 13 | end 14 | 15 | test "calls the provided function when terminated with reason :normal" do 16 | test_pid = self() 17 | {:ok, canary} = GenServer.start(Canary, fn -> send(test_pid, :notified) end) 18 | refute_receive :notified 19 | 20 | GenServer.stop(canary, :shutdown) 21 | assert_receive :notified 22 | end 23 | 24 | @tag :capture_log 25 | test "does nothing when terminated with a reason other than :shutdown or :normal" do 26 | test_pid = self() 27 | {:ok, canary} = GenServer.start(Canary, fn -> send(test_pid, :notified) end) 28 | 29 | GenServer.stop(canary, :unexpected) 30 | refute_receive :notified 31 | end 32 | 33 | test "default notify function is set if function is not provided" do 34 | Process.flag(:trap_exit, true) 35 | pid = spawn_link(Canary, :start_link, []) 36 | 37 | assert_receive {:EXIT, ^pid, :normal} 38 | end 39 | 40 | test "stops with reason if given a value that is not a function for notify_fn" do 41 | Process.flag(:trap_exit, true) 42 | 43 | assert {:error, "expect function/0 for notify_fn, got nil"} = 44 | Canary.start_link(notify_fn: nil) 45 | end 46 | end 47 | --------------------------------------------------------------------------------