From f0726ecb371b1b999788d579ed18e3ef5c6f10ac Mon Sep 17 00:00:00 2001 From: Paul James Cleary Date: Fri, 27 Jul 2018 10:18:29 -0400 Subject: [PATCH] Initial code release --- AUTHORS.md | 45 + CODE_OF_CONDUCT.md | 46 + CONTRIBUTING.md | 173 ++ DEVELOPER_GUIDE.md | 251 ++ LICENSE | 202 ++ README.md | 98 +- ROADMAP.md | 29 + bin/add-license-headers.sh | 113 + bin/build.sh | 34 + bin/docker-publish-api.sh | 10 + bin/docker-up-api-server.sh | 52 + bin/docker-up-dns-server.sh | 5 + bin/func-test-api.sh | 52 + bin/func-test-portal.sh | 46 + bin/stop-all-docker-containers.sh | 6 + bin/verify.sh | 21 + build.sbt | 299 +++ docker/api/.dockerignore | 5 + docker/api/Dockerfile | 18 + docker/api/docker.conf | 194 ++ docker/api/run.sh | 53 + docker/bind9/etc/named.conf.local | 160 ++ .../zones/1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa | 10 + .../bind9/zones/192^30.2.0.192.in-addr.arpa | 11 + docker/bind9/zones/2.0.192.in-addr.arpa | 13 + docker/bind9/zones/30.172.in-addr.arpa | 10 + docker/bind9/zones/child.parent.com.hosts | 9 + docker/bind9/zones/dummy.hosts | 14 + docker/bind9/zones/invalid-zone.hosts | 17 + .../zones/list-zones-test-searched-1.hosts | 8 + .../zones/list-zones-test-searched-2.hosts | 8 + .../zones/list-zones-test-searched-3.hosts | 8 + .../zones/list-zones-test-unfiltered-1.hosts | 8 + .../zones/list-zones-test-unfiltered-2.hosts | 8 + docker/bind9/zones/ok.hosts | 14 + docker/bind9/zones/old-shared.hosts | 14 + docker/bind9/zones/old-vinyldns2.hosts | 14 + docker/bind9/zones/old-vinyldns3.hosts | 14 + docker/bind9/zones/one-time-shared.hosts | 8 + docker/bind9/zones/one-time.hosts | 14 + docker/bind9/zones/parent.com.hosts | 15 + docker/bind9/zones/shared.hosts | 14 + docker/bind9/zones/sync-test.hosts | 17 + docker/bind9/zones/system-test-history.hosts | 14 + docker/bind9/zones/system-test.hosts | 14 + docker/bind9/zones/vinyldns.hosts | 14 + docker/docker-compose-build.yml | 48 + docker/docker-compose-func-test.yml | 56 + docker/docker-compose.yml | 31 + docker/elasticmq/Dockerfile | 10 + docker/elasticmq/custom.conf | 32 + docker/elasticmq/run.sh | 8 + docker/functest/Dockerfile | 20 + docker/functest/run-tests.py | 18 + docker/functest/run.sh | 31 + img/vinyldns-full-logo-light.png | Bin 0 -> 83879 bytes modules/api/functional_test/bootstrap.sh | 12 + .../functional_test/boto_request_signer.py | 81 + modules/api/functional_test/conftest.py | 77 + .../batch/create_batch_change_test.py | 2306 +++++++++++++++++ .../live_tests/batch/get_batch_change_test.py | 75 + .../batch/list_batch_change_summaries_test.py | 155 ++ .../functional_test/live_tests/conftest.py | 28 + .../live_tests/internal/color_test.py | 14 + .../live_tests/internal/health_test.py | 13 + .../live_tests/internal/ping_test.py | 14 + .../live_tests/internal/status_test.py | 74 + .../membership/create_group_test.py | 221 ++ .../membership/delete_group_test.py | 143 + .../membership/get_group_changes_test.py | 209 ++ .../live_tests/membership/get_group_test.py | 93 + .../membership/list_group_admins_test.py | 81 + .../membership/list_group_members_test.py | 567 ++++ .../membership/list_my_groups_test.py | 165 ++ .../membership/update_group_test.py | 616 +++++ .../live_tests/production_verify_test.py | 70 + .../recordsets/create_recordset_test.py | 1646 ++++++++++++ .../recordsets/delete_recordset_test.py | 642 +++++ .../recordsets/get_recordset_test.py | 130 + .../recordsets/list_recordset_changes_test.py | 176 ++ .../recordsets/list_recordsets_test.py | 303 +++ .../recordsets/update_recordset_test.py | 1931 ++++++++++++++ .../live_tests/shared_zone_test_context.py | 275 ++ .../functional_test/live_tests/test_data.py | 124 + .../live_tests/zone_history_context.py | 169 ++ .../live_tests/zones/create_zone_test.py | 473 ++++ .../live_tests/zones/delete_zone_test.py | 106 + .../live_tests/zones/get_zone_test.py | 77 + .../zones/list_zone_changes_test.py | 134 + .../live_tests/zones/list_zones_test.py | 292 +++ .../live_tests/zones/sync_zone_test.py | 206 ++ .../live_tests/zones/update_zone_test.py | 821 ++++++ .../perf_tests/uat_sync_test.py | 63 + modules/api/functional_test/pytest.ini | 3 + modules/api/functional_test/requirements.txt | 14 + modules/api/functional_test/run.py | 26 + modules/api/functional_test/utils.py | 532 ++++ .../api/functional_test/vinyldns_context.py | 16 + .../api/functional_test/vinyldns_python.py | 857 ++++++ modules/api/functional_test/zone_inject.py | 48 + modules/api/src/it/resources/application.conf | 114 + modules/api/src/it/resources/logback-test.xml | 18 + .../dns/DnsConversionsIntegrationSpec.scala | 63 + .../RecordSetServiceIntegrationSpec.scala | 303 +++ .../zone/ZoneServiceIntegrationSpec.scala | 156 ++ .../ZoneCommandHandlerIntegrationSpec.scala | 238 ++ ...GroupChangeRepositoryIntegrationSpec.scala | 226 ++ ...namoDBGroupRepositoryIntegrationSpec.scala | 238 ++ .../dynamodb/DynamoDBIntegrationSpec.scala | 69 + ...BMembershipRepositoryIntegrationSpec.scala | 179 ++ ...ecordChangeRepositoryIntegrationSpec.scala | 378 +++ ...DBRecordSetRepositoryIntegrationSpec.scala | 488 ++++ ...ynamoDBUserRepositoryIntegrationSpec.scala | 189 ++ ...BZoneChangeRepositoryIntegrationSpec.scala | 225 ++ ...BatchChangeRepositoryIntegrationSpec.scala | 500 ++++ .../JdbcZoneRepositoryIntegrationSpec.scala | 559 ++++ .../api/src/main/protobuf/VinylDNSProto.proto | 180 ++ .../api/src/main/resources/application.conf | 66 + .../api/src/main/resources/db-migrations.conf | 119 + .../main/resources/db/migration/V1__Zones.sql | 33 + .../db/migration/V2__BatchChanges.sql | 44 + modules/api/src/main/resources/logback.xml | 24 + modules/api/src/main/resources/reference.conf | 148 ++ .../api/src/main/resources/test/logback.xml | 24 + .../api/src/main/resources/vinyldns-ascii.txt | 6 + .../scala/db/migration/MigrationRunner.scala | 58 + .../src/main/scala/vinyldns/api/Boot.scala | 189 ++ .../scala/vinyldns/api/Instrumented.scala | 56 + .../main/scala/vinyldns/api/Interfaces.scala | 124 + .../scala/vinyldns/api/VinylDNSConfig.scala | 61 + .../api/domain/AccessValidations.scala | 205 ++ .../api/domain/AccessValidationsAlgebra.scala | 60 + .../api/domain/DomainValidationErrors.scala | 124 + .../api/domain/DomainValidations.scala | 155 ++ .../api/domain/ReverseZoneHelpers.scala | 148 ++ .../api/domain/ValidationImprovements.scala | 36 + .../api/domain/auth/AuthPrincipal.scala | 29 + .../domain/auth/AuthPrincipalProvider.scala | 60 + .../api/domain/batch/BatchChange.scala | 343 +++ .../domain/batch/BatchChangeConverter.scala | 219 ++ .../batch/BatchChangeConverterAlgebra.scala | 32 + .../domain/batch/BatchChangeInterfaces.scala | 118 + .../domain/batch/BatchChangeProtocol.scala | 58 + .../domain/batch/BatchChangeRepository.scala | 45 + .../api/domain/batch/BatchChangeService.scala | 250 ++ .../batch/BatchChangeServiceAlgebra.scala | 35 + .../domain/batch/BatchChangeValidations.scala | 338 +++ .../api/domain/dns/DnsConnection.scala | 222 ++ .../api/domain/dns/DnsConversions.scala | 527 ++++ .../api/domain/engine/EngineCommandBus.scala | 28 + .../api/domain/membership/Group.scala | 139 + .../membership/GroupChangeRepository.scala | 40 + .../domain/membership/GroupRepository.scala | 54 + .../membership/MembershipProtocol.scala | 151 ++ .../membership/MembershipRepository.scala | 40 + .../domain/membership/MembershipService.scala | 226 ++ .../membership/MembershipServiceAlgebra.scala | 58 + .../membership/MembershipValidations.scala | 41 + .../vinyldns/api/domain/membership/User.scala | 33 + .../domain/membership/UserRepository.scala | 150 ++ .../api/domain/record/ChangeSet.scala | 94 + .../record/RecordChangeRepository.scala | 51 + .../api/domain/record/RecordSet.scala | 147 ++ .../api/domain/record/RecordSetChange.scala | 244 ++ .../domain/record/RecordSetRepository.scala | 54 + .../api/domain/record/RecordSetService.scala | 190 ++ .../record/RecordSetServiceAlgebra.scala | 57 + .../domain/record/RecordSetValidations.scala | 189 ++ .../vinyldns/api/domain/zone/ACLRule.scala | 64 + .../api/domain/zone/AclRuleOrdering.scala | 66 + .../scala/vinyldns/api/domain/zone/Zone.scala | 177 ++ .../vinyldns/api/domain/zone/ZoneChange.scala | 134 + .../domain/zone/ZoneChangeRepository.scala | 47 + .../domain/zone/ZoneConnectionValidator.scala | 100 + .../api/domain/zone/ZoneProtocol.scala | 226 ++ .../domain/zone/ZoneRecordValidations.scala | 72 + .../api/domain/zone/ZoneRepository.scala | 48 + .../api/domain/zone/ZoneService.scala | 234 ++ .../api/domain/zone/ZoneServiceAlgebra.scala | 56 + .../api/domain/zone/ZoneValidations.scala | 79 + .../vinyldns/api/domain/zone/ZoneView.scala | 81 + .../api/domain/zone/ZoneViewLoader.scala | 93 + .../vinyldns/api/engine/DnsConnector.scala | 43 + .../api/engine/RecordSetChangeHandler.scala | 250 ++ .../api/engine/ZoneChangeHandler.scala | 38 + .../api/engine/ZoneCommandHandler.scala | 267 ++ .../vinyldns/api/engine/ZoneSyncHandler.scala | 109 + .../api/engine/sqs/SqsCommandBus.scala | 43 + .../vinyldns/api/engine/sqs/SqsConfig.scala | 38 + .../api/engine/sqs/SqsConnection.scala | 116 + .../api/engine/sqs/SqsConverters.scala | 109 + .../BatchChangeProtobufConversions.scala | 141 + .../protobuf/GroupProtobufConversions.scala | 81 + .../api/protobuf/ProtobufConversions.scala | 337 +++ .../repository/dynamodb/DynamoDBClient.scala | 45 + .../DynamoDBGroupChangeRepository.scala | 175 ++ .../dynamodb/DynamoDBGroupRepository.scala | 257 ++ .../repository/dynamodb/DynamoDBHelper.scala | 327 +++ .../DynamoDBMembershipRepository.scala | 170 ++ .../DynamoDBRecordChangeRepository.scala | 369 +++ .../DynamoDBRecordSetConversions.scala | 123 + .../DynamoDBRecordSetRepository.scala | 240 ++ .../dynamodb/DynamoDBUserRepository.scala | 245 ++ .../DynamoDBZoneChangeRepository.scala | 253 ++ .../api/repository/dynamodb/QueryHelper.scala | 206 ++ .../mysql/JdbcBatchChangeRepository.scala | 311 +++ .../repository/mysql/JdbcZoneRepository.scala | 380 +++ .../api/repository/mysql/VinylDNSJDBC.scala | 101 + .../vinyldns/api/route/ACLJsonProtocol.scala | 52 + .../api/route/Aws4Authenticator.scala | 256 ++ .../api/route/BatchChangeJsonProtocol.scala | 176 ++ .../api/route/BatchChangeRouting.scala | 91 + .../vinyldns/api/route/BlueGreenRouting.scala | 38 + .../vinyldns/api/route/DnsJsonProtocol.scala | 429 +++ .../api/route/HealthCheckRouting.scala | 52 + .../vinyldns/api/route/HealthService.scala | 32 + .../vinyldns/api/route/JsonValidation.scala | 349 +++ .../api/route/MembershipJsonProtocol.scala | 122 + .../api/route/MembershipRouting.scala | 187 ++ .../scala/vinyldns/api/route/Monitor.scala | 129 + .../api/route/PrometheusRouting.scala | 54 + .../vinyldns/api/route/RecordSetRouting.scala | 162 ++ .../vinyldns/api/route/StatusRouting.scala | 65 + .../api/route/VinylDNSAuthentication.scala | 172 ++ .../api/route/VinylDNSDirectives.scala | 96 + .../api/route/VinylDNSJsonProtocol.scala | 32 + .../vinyldns/api/route/VinylDNSService.scala | 141 + .../vinyldns/api/route/ZoneRouting.scala | 158 ++ .../scala/vinyldns/core/crypto/Crypto.scala | 33 + .../api/src/test/resources/application.conf | 132 + .../api/src/test/resources/logback-test.xml | 19 + .../scala/vinyldns/api/AkkaTestJawn.scala | 86 + .../test/scala/vinyldns/api/CatsHelpers.scala | 76 + .../scala/vinyldns/api/DomainGenerator.scala | 53 + .../scala/vinyldns/api/GroupTestData.scala | 204 ++ .../vinyldns/api/IpAddressGenerator.scala | 52 + .../vinyldns/api/VinylDNSConfigSpec.scala | 78 + .../scala/vinyldns/api/VinylDNSTestData.scala | 368 +++ .../api/domain/AccessValidationsSpec.scala | 781 ++++++ .../api/domain/DomainValidationsSpec.scala | 154 ++ .../api/domain/ReverseZoneHelpersSpec.scala | 307 +++ .../MembershipAuthPrincipalProviderSpec.scala | 95 + .../batch/BatchChangeConverterSpec.scala | 430 +++ .../domain/batch/BatchChangeInputSpec.scala | 47 + .../batch/BatchChangeInterfacesSpec.scala | 111 + .../domain/batch/BatchChangeServiceSpec.scala | 691 +++++ .../api/domain/batch/BatchChangeSpec.scala | 121 + .../batch/BatchChangeValidationsSpec.scala | 1278 +++++++++ .../api/domain/dns/DnsConnectionSpec.scala | 527 ++++ .../api/domain/dns/DnsConversionsSpec.scala | 603 +++++ .../api/domain/membership/GroupSpec.scala | 86 + .../membership/MembershipServiceSpec.scala | 753 ++++++ .../MembershipValidationsSpec.scala | 91 + .../api/domain/record/ChangeSetSpec.scala | 31 + .../domain/record/RecordSetChangeSpec.scala | 101 + .../domain/record/RecordSetServiceSpec.scala | 451 ++++ .../api/domain/record/RecordSetSpec.scala | 193 ++ .../record/RecordSetValidationsSpec.scala | 279 ++ .../api/domain/zone/ACLRuleSpec.scala | 129 + .../api/domain/zone/AclRuleOrderingSpec.scala | 154 ++ .../api/domain/zone/ZoneACLSpec.scala | 91 + .../api/domain/zone/ZoneChangeSpec.scala | 38 + .../api/domain/zone/ZoneConnectionSpec.scala | 159 ++ .../zone/ZoneConnectionValidatorSpec.scala | 194 ++ .../zone/ZoneRecordValidationsSpec.scala | 126 + .../api/domain/zone/ZoneServiceSpec.scala | 528 ++++ .../vinyldns/api/domain/zone/ZoneSpec.scala | 100 + .../api/domain/zone/ZoneValidationsSpec.scala | 131 + .../api/domain/zone/ZoneViewLoaderSpec.scala | 344 +++ .../api/domain/zone/ZoneViewSpec.scala | 272 ++ .../engine/RecordSetChangeHandlerSpec.scala | 710 +++++ .../api/engine/ZoneChangeHandlerSpec.scala | 52 + .../api/engine/ZoneCommandHandlerSpec.scala | 416 +++ .../api/engine/ZoneSyncHandlerSpec.scala | 358 +++ .../api/engine/sqs/SqsConvertersSpec.scala | 121 + .../api/engine/sqs/TestSqsService.scala | 35 + .../BatchChangeProtobufConversionsSpec.scala | 92 + .../GroupProtobufConversionsSpec.scala | 103 + .../protobuf/ProtobufConversionsSpec.scala | 737 ++++++ .../api/repository/EmptyRepositories.scala | 71 + .../InMemoryBatchChangeRepository.scala | 132 + .../InMemoryBatchChangeRepositorySpec.scala | 148 ++ .../dynamodb/DynamoDBClientSpec.scala | 42 + .../DynamoDBGroupChangeRepositorySpec.scala | 259 ++ .../DynamoDBGroupRepositorySpec.scala | 316 +++ .../dynamodb/DynamoDBHelperSpec.scala | 1128 ++++++++ .../DynamoDBMembershipRepositorySpec.scala | 256 ++ .../DynamoDBRecordChangeRepositorySpec.scala | 398 +++ .../DynamoDBRecordSetConversionsSpec.scala | 188 ++ .../DynamoDBRecordSetRepositorySpec.scala | 423 +++ .../dynamodb/DynamoDBUserRepositorySpec.scala | 408 +++ .../DynamoDBZoneChangeRepositorySpec.scala | 264 ++ .../repository/dynamodb/QueryHelperSpec.scala | 222 ++ .../dynamodb/TestDynamoDBHelper.scala | 33 + .../api/route/ACLRuleInfoSerializerSpec.scala | 125 + .../api/route/Aws4AuthenticatorSpec.scala | 36 + .../route/BatchChangeJsonProtocolSpec.scala | 409 +++ .../api/route/BatchChangeRoutingSpec.scala | 410 +++ .../api/route/BlueGreenRoutingSpec.scala | 44 + .../CreateGroupInputSerializerSpec.scala | 102 + .../api/route/GroupSerializerSpec.scala | 115 + .../api/route/HealthCheckRoutingSpec.scala | 62 + .../api/route/HealthServiceSpec.scala | 52 + .../api/route/JsonValidationSpec.scala | 238 ++ .../api/route/MembershipRoutingSpec.scala | 675 +++++ .../vinyldns/api/route/MonitorSpec.scala | 185 ++ .../vinyldns/api/route/PingRoutingSpec.scala | 42 + .../api/route/PrometheusRoutingSpec.scala | 55 + .../api/route/RecordSetRoutingSpec.scala | 1174 +++++++++ .../api/route/StatusRoutingSpec.scala | 79 + .../UpdateGroupInputSerializerSpec.scala | 113 + .../api/route/VinylDNSAuthenticatorSpec.scala | 241 ++ .../api/route/VinylDNSDirectivesSpec.scala | 90 + .../api/route/VinylDNSJsonProtocolSpec.scala | 319 +++ .../api/route/VinylDNSServiceSpec.scala | 240 ++ .../vinyldns/api/route/ZoneRoutingSpec.scala | 1014 ++++++++ .../universal/bin/wait-for-dependencies.sh | 28 + .../api/src/universal/conf/application.conf | 156 ++ modules/api/src/universal/conf/logback.xml | 12 + .../vinyldns/core/crypto/CryptoAlgebra.scala | 38 + .../vinyldns/core/crypto/NoOpCrypto.scala | 32 + .../core/crypto/CryptoAlgebraSpec.scala | 65 + .../vinyldns/core/crypto/NoOpCryptoSpec.scala | 41 + .../main/resources/microsite/data/menu.yml | 152 ++ .../src/main/tut/apidocs/auth-mechanism.md | 46 + modules/docs/src/main/tut/apidocs/basics.md | 70 + .../main/tut/apidocs/batchchange-errors.md | 435 ++++ .../src/main/tut/apidocs/batchchange-model.md | 131 + .../main/tut/apidocs/create-batchchange.md | 198 ++ .../docs/src/main/tut/apidocs/create-group.md | 89 + .../src/main/tut/apidocs/create-recordset.md | 103 + .../docs/src/main/tut/apidocs/create-zone.md | 75 + .../docs/src/main/tut/apidocs/delete-group.md | 61 + .../src/main/tut/apidocs/delete-recordset.md | 93 + .../docs/src/main/tut/apidocs/delete-zone.md | 65 + modules/docs/src/main/tut/apidocs/faq.md | 85 + .../src/main/tut/apidocs/get-batchchange.md | 87 + .../docs/src/main/tut/apidocs/get-group.md | 59 + .../main/tut/apidocs/get-recordset-change.md | 76 + .../src/main/tut/apidocs/get-recordset.md | 61 + modules/docs/src/main/tut/apidocs/get-zone.md | 60 + .../docs/src/main/tut/apidocs/getting-help.md | 13 + modules/docs/src/main/tut/apidocs/index.md | 23 + .../src/main/tut/apidocs/list-batchchanges.md | 80 + .../main/tut/apidocs/list-group-activity.md | 103 + .../src/main/tut/apidocs/list-group-admins.md | 52 + .../main/tut/apidocs/list-group-members.md | 65 + .../docs/src/main/tut/apidocs/list-groups.md | 82 + .../tut/apidocs/list-recordset-changes.md | 523 ++++ .../src/main/tut/apidocs/list-recordsets.md | 156 ++ .../src/main/tut/apidocs/list-zone-changes.md | 93 + .../docs/src/main/tut/apidocs/list-zones.md | 113 + .../src/main/tut/apidocs/membership-model.md | 90 + .../src/main/tut/apidocs/recordset-model.md | 159 ++ .../docs/src/main/tut/apidocs/sync-zone.md | 74 + modules/docs/src/main/tut/apidocs/tools.md | 9 + .../docs/src/main/tut/apidocs/update-group.md | 100 + .../src/main/tut/apidocs/update-recordset.md | 125 + .../docs/src/main/tut/apidocs/update-zone.md | 89 + .../docs/src/main/tut/apidocs/zone-model.md | 245 ++ .../main/tut/img/large-denis-logo-crop.png | Bin 0 -> 52859 bytes modules/docs/src/main/tut/index.md | 23 + modules/portal/.gitignore | 13 + modules/portal/Gruntfile.js | 85 + modules/portal/README.md | 95 + modules/portal/app/Module.scala | 90 + .../app/controllers/ChangeLogStore.scala | 59 + .../app/controllers/FrontendController.scala | 109 + .../app/controllers/LdapAuthenticator.scala | 242 ++ modules/portal/app/controllers/Settings.scala | 52 + .../app/controllers/UserAccountAccessor.scala | 57 + .../app/controllers/UserAccountStore.scala | 36 + modules/portal/app/controllers/VinylDNS.scala | 632 +++++ .../datastores/DynamoDBChangeLogStore.scala | 88 + .../datastores/DynamoDBUserAccountStore.scala | 214 ++ .../datastores/InMemoryChangeLogStore.scala | 36 + .../datastores/InMemoryUserAccountStore.scala | 52 + modules/portal/app/models/CustomLinks.scala | 49 + modules/portal/app/models/UserAccount.scala | 64 + modules/portal/app/models/VinylRequest.scala | 69 + .../batchChanges/batchChangeDetail.scala.html | 135 + .../batchChanges/batchChangeNew.scala.html | 180 ++ .../batchChanges/batchChanges.scala.html | 104 + .../app/views/groups/groupDetail.scala.html | 114 + .../portal/app/views/groups/groups.scala.html | 192 ++ modules/portal/app/views/header.scala.html | 28 + modules/portal/app/views/login.scala.html | 64 + modules/portal/app/views/main.scala.html | 162 ++ .../app/views/zones/zoneDetail.scala.html | 77 + .../zones/zoneTabs/changeHistory.scala.html | 79 + .../zones/zoneTabs/manageRecords.scala.html | 304 +++ .../zones/zoneTabs/manageZone.scala.html | 436 ++++ .../portal/app/views/zones/zones.scala.html | 179 ++ modules/portal/conf/application-test.conf | 62 + modules/portal/conf/application.conf | 114 + modules/portal/conf/logback.xml | 21 + modules/portal/conf/routes | 57 + modules/portal/karma.conf.js | 70 + modules/portal/package.json | 47 + modules/portal/prepare-portal.sh | 12 + modules/portal/public/app.js | 42 + modules/portal/public/css/theme-overrides.css | 148 ++ modules/portal/public/css/vinyldns.css | 380 +++ modules/portal/public/images/email.svg | 15 + modules/portal/public/images/favicon.ico | Bin 0 -> 15086 bytes .../images/vinyldns-full-logo-light.png | Bin 0 -> 83879 bytes .../portal/public/images/vinyldns-logo.png | Bin 0 -> 83879 bytes .../portal/public/images/vinyldns-portal.png | Bin 0 -> 83879 bytes modules/portal/public/js/custom.js | 42 + .../batch-change-detail.controller.js | 53 + .../batch-change-new.controller.js | 93 + .../batch-change/batch-change.directive.js | 74 + .../lib/batch-change/batch-change.module.js | 21 + .../lib/batch-change/batch-change.service.js | 43 + .../lib/batch-change/batch-change.spec.js | 327 +++ .../batch-change/batch-changes.controller.js | 106 + modules/portal/public/lib/constants.js | 21 + .../lib/controllers/controller.groups.js | 178 ++ .../lib/controllers/controller.groups.spec.js | 114 + .../lib/controllers/controller.manageZones.js | 277 ++ .../controller.manageZones.spec.js | 513 ++++ .../lib/controllers/controller.membership.js | 214 ++ .../controllers/controller.membership.spec.js | 395 +++ .../lib/controllers/controller.records.js | 500 ++++ .../controllers/controller.records.spec.js | 252 ++ .../lib/controllers/controller.zones.js | 193 ++ .../lib/controllers/controller.zones.spec.js | 102 + .../public/lib/controllers/controllers.js | 24 + .../public/lib/directives/directives.js | 17 + .../lib/directives/directives.modals.body.js | 25 + .../directives/directives.modals.element.js | 29 + .../directives/directives.modals.footer.js | 25 + .../directives/directives.modals.invalid.js | 26 + .../lib/directives/directives.modals.js | 20 + .../lib/directives/directives.modals.modal.js | 29 + .../directives/directives.modals.record.js | 24 + .../directives.modals.zoneconnection.js | 24 + .../directives/directives.notifications.js | 32 + .../lib/directives/directives.validations.js | 17 + .../directives.validations.zones.js | 43 + .../lib/services/groups/service.groups.js | 100 + .../services/groups/service.groups.spec.js | 517 ++++ .../lib/services/paging/service.paging.js | 95 + .../services/paging/service.paging.spec.js | 105 + .../lib/services/profile/service.profile.js | 28 + .../services/profile/service.profile.spec.js | 98 + .../lib/services/records/service.records.js | 239 ++ .../services/records/service.records.spec.js | 252 ++ .../portal/public/lib/services/services.js | 17 + .../lib/services/utility/service.utility.js | 70 + .../services/utility/service.utility.spec.js | 95 + .../lib/services/zones/service.zones.js | 160 ++ .../lib/services/zones/service.zones.spec.js | 400 +++ modules/portal/public/mocks/mockARecord.json | 20 + .../public/mocks/mockARecordSubmit.json | 11 + .../public/mocks/mockBadARecordSubmit.json | 11 + .../mocks/mockBadGroupGetMemberList.json | 12 + .../public/mocks/mockBadZoneSubmit.json | 10 + .../public/mocks/mockEmptyGroupList.json | 3 + modules/portal/public/mocks/mockGroup.json | 20 + .../public/mocks/mockGroupBadSubmit.json | 4 + .../public/mocks/mockGroupExistingSubmit.json | 5 + .../public/mocks/mockGroupGetMemberList.json | 17 + .../portal/public/mocks/mockGroupList.json | 7 + .../public/mocks/mockGroupListChanges.json | 31 + .../portal/public/mocks/mockGroupSubmit.json | 6 + modules/portal/public/mocks/mockHistory.json | 79 + modules/portal/public/mocks/mockUserData.json | 8 + modules/portal/public/mocks/mockZone.json | 33 + .../portal/public/mocks/mockZoneSubmit.json | 11 + .../portal/public/templates/modal-body.html | 3 + .../public/templates/modal-element.html | 6 + .../portal/public/templates/modal-footer.html | 1 + .../public/templates/modal-invalid.html | 1 + modules/portal/public/templates/modal.html | 12 + .../portal/public/templates/notification.html | 5 + .../portal/public/templates/record-modal.html | 347 +++ .../templates/zoneconnection-modal.html | 103 + modules/portal/run_all_tests.sh | 39 + .../controllers/FrontendControllerSpec.scala | 258 ++ .../controllers/LdapAuthenticatorSpec.scala | 440 ++++ .../controllers/UserAccountAccessorSpec.scala | 172 ++ .../test/controllers/VinylDNSSpec.scala | 1440 ++++++++++ .../DynamoDBChangeLogStoreSpec.scala | 66 + .../DynamoDBUserAccountStoreSpec.scala | 229 ++ .../InMemoryChangeLogStoreSpec.scala | 36 + .../InMemoryUserAccountStoreSpec.scala | 82 + .../portal/test/models/CustomLinksSpec.scala | 72 + .../portal/test/models/UserAccountSpec.scala | 44 + .../portal/test/models/VinylRequestSpec.scala | 193 ++ project/CompilerOptions.scala | 39 + project/Dependencies.scala | 81 + project/Resolvers.scala | 11 + project/assembly.sbt | 1 + project/build.properties | 1 + project/plugins.sbt | 37 + scalastyle-config.xml | 79 + scalastyle-test-config.xml | 81 + version.sbt | 1 + 499 files changed, 78666 insertions(+), 2 deletions(-) create mode 100644 AUTHORS.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 DEVELOPER_GUIDE.md create mode 100644 LICENSE create mode 100644 ROADMAP.md create mode 100755 bin/add-license-headers.sh create mode 100755 bin/build.sh create mode 100755 bin/docker-publish-api.sh create mode 100755 bin/docker-up-api-server.sh create mode 100755 bin/docker-up-dns-server.sh create mode 100755 bin/func-test-api.sh create mode 100755 bin/func-test-portal.sh create mode 100755 bin/stop-all-docker-containers.sh create mode 100755 bin/verify.sh create mode 100644 build.sbt create mode 100644 docker/api/.dockerignore create mode 100644 docker/api/Dockerfile create mode 100644 docker/api/docker.conf create mode 100644 docker/api/run.sh create mode 100755 docker/bind9/etc/named.conf.local create mode 100755 docker/bind9/zones/1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa create mode 100644 docker/bind9/zones/192^30.2.0.192.in-addr.arpa create mode 100644 docker/bind9/zones/2.0.192.in-addr.arpa create mode 100755 docker/bind9/zones/30.172.in-addr.arpa create mode 100644 docker/bind9/zones/child.parent.com.hosts create mode 100644 docker/bind9/zones/dummy.hosts create mode 100644 docker/bind9/zones/invalid-zone.hosts create mode 100644 docker/bind9/zones/list-zones-test-searched-1.hosts create mode 100644 docker/bind9/zones/list-zones-test-searched-2.hosts create mode 100644 docker/bind9/zones/list-zones-test-searched-3.hosts create mode 100755 docker/bind9/zones/list-zones-test-unfiltered-1.hosts create mode 100755 docker/bind9/zones/list-zones-test-unfiltered-2.hosts create mode 100755 docker/bind9/zones/ok.hosts create mode 100755 docker/bind9/zones/old-shared.hosts create mode 100755 docker/bind9/zones/old-vinyldns2.hosts create mode 100755 docker/bind9/zones/old-vinyldns3.hosts create mode 100755 docker/bind9/zones/one-time-shared.hosts create mode 100755 docker/bind9/zones/one-time.hosts create mode 100755 docker/bind9/zones/parent.com.hosts create mode 100755 docker/bind9/zones/shared.hosts create mode 100755 docker/bind9/zones/sync-test.hosts create mode 100755 docker/bind9/zones/system-test-history.hosts create mode 100755 docker/bind9/zones/system-test.hosts create mode 100644 docker/bind9/zones/vinyldns.hosts create mode 100644 docker/docker-compose-build.yml create mode 100644 docker/docker-compose-func-test.yml create mode 100644 docker/docker-compose.yml create mode 100644 docker/elasticmq/Dockerfile create mode 100644 docker/elasticmq/custom.conf create mode 100755 docker/elasticmq/run.sh create mode 100644 docker/functest/Dockerfile create mode 100644 docker/functest/run-tests.py create mode 100644 docker/functest/run.sh create mode 100644 img/vinyldns-full-logo-light.png create mode 100755 modules/api/functional_test/bootstrap.sh create mode 100644 modules/api/functional_test/boto_request_signer.py create mode 100644 modules/api/functional_test/conftest.py create mode 100644 modules/api/functional_test/live_tests/batch/create_batch_change_test.py create mode 100644 modules/api/functional_test/live_tests/batch/get_batch_change_test.py create mode 100644 modules/api/functional_test/live_tests/batch/list_batch_change_summaries_test.py create mode 100644 modules/api/functional_test/live_tests/conftest.py create mode 100644 modules/api/functional_test/live_tests/internal/color_test.py create mode 100644 modules/api/functional_test/live_tests/internal/health_test.py create mode 100644 modules/api/functional_test/live_tests/internal/ping_test.py create mode 100644 modules/api/functional_test/live_tests/internal/status_test.py create mode 100644 modules/api/functional_test/live_tests/membership/create_group_test.py create mode 100644 modules/api/functional_test/live_tests/membership/delete_group_test.py create mode 100644 modules/api/functional_test/live_tests/membership/get_group_changes_test.py create mode 100644 modules/api/functional_test/live_tests/membership/get_group_test.py create mode 100644 modules/api/functional_test/live_tests/membership/list_group_admins_test.py create mode 100644 modules/api/functional_test/live_tests/membership/list_group_members_test.py create mode 100644 modules/api/functional_test/live_tests/membership/list_my_groups_test.py create mode 100644 modules/api/functional_test/live_tests/membership/update_group_test.py create mode 100644 modules/api/functional_test/live_tests/production_verify_test.py create mode 100644 modules/api/functional_test/live_tests/recordsets/create_recordset_test.py create mode 100644 modules/api/functional_test/live_tests/recordsets/delete_recordset_test.py create mode 100644 modules/api/functional_test/live_tests/recordsets/get_recordset_test.py create mode 100644 modules/api/functional_test/live_tests/recordsets/list_recordset_changes_test.py create mode 100644 modules/api/functional_test/live_tests/recordsets/list_recordsets_test.py create mode 100644 modules/api/functional_test/live_tests/recordsets/update_recordset_test.py create mode 100644 modules/api/functional_test/live_tests/shared_zone_test_context.py create mode 100644 modules/api/functional_test/live_tests/test_data.py create mode 100644 modules/api/functional_test/live_tests/zone_history_context.py create mode 100644 modules/api/functional_test/live_tests/zones/create_zone_test.py create mode 100644 modules/api/functional_test/live_tests/zones/delete_zone_test.py create mode 100644 modules/api/functional_test/live_tests/zones/get_zone_test.py create mode 100644 modules/api/functional_test/live_tests/zones/list_zone_changes_test.py create mode 100644 modules/api/functional_test/live_tests/zones/list_zones_test.py create mode 100644 modules/api/functional_test/live_tests/zones/sync_zone_test.py create mode 100644 modules/api/functional_test/live_tests/zones/update_zone_test.py create mode 100644 modules/api/functional_test/perf_tests/uat_sync_test.py create mode 100644 modules/api/functional_test/pytest.ini create mode 100644 modules/api/functional_test/requirements.txt create mode 100755 modules/api/functional_test/run.py create mode 100644 modules/api/functional_test/utils.py create mode 100644 modules/api/functional_test/vinyldns_context.py create mode 100644 modules/api/functional_test/vinyldns_python.py create mode 100644 modules/api/functional_test/zone_inject.py create mode 100644 modules/api/src/it/resources/application.conf create mode 100644 modules/api/src/it/resources/logback-test.xml create mode 100644 modules/api/src/it/scala/vinyldns/api/domain/dns/DnsConversionsIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/domain/zone/ZoneServiceIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/engine/ZoneCommandHandlerIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupChangeRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBMembershipRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordChangeRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBUserRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBZoneChangeRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcBatchChangeRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcZoneRepositoryIntegrationSpec.scala create mode 100644 modules/api/src/main/protobuf/VinylDNSProto.proto create mode 100644 modules/api/src/main/resources/application.conf create mode 100644 modules/api/src/main/resources/db-migrations.conf create mode 100644 modules/api/src/main/resources/db/migration/V1__Zones.sql create mode 100644 modules/api/src/main/resources/db/migration/V2__BatchChanges.sql create mode 100644 modules/api/src/main/resources/logback.xml create mode 100644 modules/api/src/main/resources/reference.conf create mode 100644 modules/api/src/main/resources/test/logback.xml create mode 100644 modules/api/src/main/resources/vinyldns-ascii.txt create mode 100644 modules/api/src/main/scala/db/migration/MigrationRunner.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/Boot.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/Instrumented.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/Interfaces.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/VinylDNSConfig.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/AccessValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/AccessValidationsAlgebra.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/DomainValidationErrors.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/ReverseZoneHelpers.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/ValidationImprovements.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/auth/AuthPrincipal.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/auth/AuthPrincipalProvider.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChange.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverterAlgebra.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeInterfaces.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeService.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeServiceAlgebra.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/dns/DnsConnection.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/dns/DnsConversions.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/engine/EngineCommandBus.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/Group.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/GroupChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/GroupRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipServiceAlgebra.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/User.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/membership/UserRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/ChangeSet.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordSet.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetChange.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetServiceAlgebra.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ACLRule.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/AclRuleOrdering.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/Zone.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneChange.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneConnectionValidator.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneRecordValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneValidations.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneView.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneViewLoader.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/DnsConnector.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/RecordSetChangeHandler.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/ZoneChangeHandler.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/ZoneCommandHandler.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/ZoneSyncHandler.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/sqs/SqsCommandBus.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/sqs/SqsConfig.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/sqs/SqsConnection.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/engine/sqs/SqsConverters.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/protobuf/BatchChangeProtobufConversions.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/protobuf/GroupProtobufConversions.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/protobuf/ProtobufConversions.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBClient.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBHelper.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBMembershipRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetConversions.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBUserRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/DynamoDBZoneChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/dynamodb/QueryHelper.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/mysql/JdbcBatchChangeRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/mysql/JdbcZoneRepository.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/repository/mysql/VinylDNSJDBC.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/ACLJsonProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/Aws4Authenticator.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/BatchChangeJsonProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/BatchChangeRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/BlueGreenRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/DnsJsonProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/HealthCheckRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/HealthService.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/JsonValidation.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/MembershipJsonProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/MembershipRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/Monitor.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/PrometheusRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/StatusRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/VinylDNSAuthentication.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/VinylDNSDirectives.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/VinylDNSJsonProtocol.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/VinylDNSService.scala create mode 100644 modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala create mode 100644 modules/api/src/main/scala/vinyldns/core/crypto/Crypto.scala create mode 100644 modules/api/src/test/resources/application.conf create mode 100644 modules/api/src/test/resources/logback-test.xml create mode 100644 modules/api/src/test/scala/vinyldns/api/AkkaTestJawn.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/CatsHelpers.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/DomainGenerator.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/GroupTestData.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/IpAddressGenerator.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/VinylDNSConfigSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/VinylDNSTestData.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/AccessValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/DomainValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/ReverseZoneHelpersSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/auth/MembershipAuthPrincipalProviderSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeConverterSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeInputSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeInterfacesSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeServiceSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/batch/BatchChangeValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/dns/DnsConnectionSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/dns/DnsConversionsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/membership/GroupSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipServiceSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/membership/MembershipValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/record/ChangeSetSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetChangeSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ACLRuleSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/AclRuleOrderingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneACLSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneChangeSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneConnectionSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneConnectionValidatorSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneRecordValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneValidationsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneViewLoaderSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneViewSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/engine/RecordSetChangeHandlerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/engine/ZoneChangeHandlerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/engine/ZoneCommandHandlerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/engine/ZoneSyncHandlerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/engine/sqs/SqsConvertersSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/engine/sqs/TestSqsService.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/protobuf/BatchChangeProtobufConversionsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/protobuf/GroupProtobufConversionsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/protobuf/ProtobufConversionsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/EmptyRepositories.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/InMemoryBatchChangeRepository.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/InMemoryBatchChangeRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBClientSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupChangeRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBHelperSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBMembershipRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordChangeRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetConversionsSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBUserRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/DynamoDBZoneChangeRepositorySpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/QueryHelperSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/repository/dynamodb/TestDynamoDBHelper.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/ACLRuleInfoSerializerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/Aws4AuthenticatorSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/BatchChangeJsonProtocolSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/BatchChangeRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/BlueGreenRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/CreateGroupInputSerializerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/GroupSerializerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/HealthCheckRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/HealthServiceSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/JsonValidationSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/MembershipRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/MonitorSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/PingRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/PrometheusRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/RecordSetRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/StatusRoutingSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/UpdateGroupInputSerializerSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/VinylDNSAuthenticatorSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/VinylDNSDirectivesSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/VinylDNSJsonProtocolSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/VinylDNSServiceSpec.scala create mode 100644 modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala create mode 100755 modules/api/src/universal/bin/wait-for-dependencies.sh create mode 100644 modules/api/src/universal/conf/application.conf create mode 100644 modules/api/src/universal/conf/logback.xml create mode 100644 modules/core/src/main/scala/vinyldns/core/crypto/CryptoAlgebra.scala create mode 100644 modules/core/src/main/scala/vinyldns/core/crypto/NoOpCrypto.scala create mode 100644 modules/core/src/test/scala/vinyldns/core/crypto/CryptoAlgebraSpec.scala create mode 100644 modules/core/src/test/scala/vinyldns/core/crypto/NoOpCryptoSpec.scala create mode 100644 modules/docs/src/main/resources/microsite/data/menu.yml create mode 100644 modules/docs/src/main/tut/apidocs/auth-mechanism.md create mode 100755 modules/docs/src/main/tut/apidocs/basics.md create mode 100644 modules/docs/src/main/tut/apidocs/batchchange-errors.md create mode 100644 modules/docs/src/main/tut/apidocs/batchchange-model.md create mode 100644 modules/docs/src/main/tut/apidocs/create-batchchange.md create mode 100755 modules/docs/src/main/tut/apidocs/create-group.md create mode 100755 modules/docs/src/main/tut/apidocs/create-recordset.md create mode 100755 modules/docs/src/main/tut/apidocs/create-zone.md create mode 100755 modules/docs/src/main/tut/apidocs/delete-group.md create mode 100755 modules/docs/src/main/tut/apidocs/delete-recordset.md create mode 100755 modules/docs/src/main/tut/apidocs/delete-zone.md create mode 100755 modules/docs/src/main/tut/apidocs/faq.md create mode 100644 modules/docs/src/main/tut/apidocs/get-batchchange.md create mode 100755 modules/docs/src/main/tut/apidocs/get-group.md create mode 100755 modules/docs/src/main/tut/apidocs/get-recordset-change.md create mode 100755 modules/docs/src/main/tut/apidocs/get-recordset.md create mode 100755 modules/docs/src/main/tut/apidocs/get-zone.md create mode 100755 modules/docs/src/main/tut/apidocs/getting-help.md create mode 100755 modules/docs/src/main/tut/apidocs/index.md create mode 100644 modules/docs/src/main/tut/apidocs/list-batchchanges.md create mode 100755 modules/docs/src/main/tut/apidocs/list-group-activity.md create mode 100755 modules/docs/src/main/tut/apidocs/list-group-admins.md create mode 100755 modules/docs/src/main/tut/apidocs/list-group-members.md create mode 100755 modules/docs/src/main/tut/apidocs/list-groups.md create mode 100755 modules/docs/src/main/tut/apidocs/list-recordset-changes.md create mode 100755 modules/docs/src/main/tut/apidocs/list-recordsets.md create mode 100755 modules/docs/src/main/tut/apidocs/list-zone-changes.md create mode 100755 modules/docs/src/main/tut/apidocs/list-zones.md create mode 100755 modules/docs/src/main/tut/apidocs/membership-model.md create mode 100755 modules/docs/src/main/tut/apidocs/recordset-model.md create mode 100755 modules/docs/src/main/tut/apidocs/sync-zone.md create mode 100755 modules/docs/src/main/tut/apidocs/tools.md create mode 100755 modules/docs/src/main/tut/apidocs/update-group.md create mode 100755 modules/docs/src/main/tut/apidocs/update-recordset.md create mode 100755 modules/docs/src/main/tut/apidocs/update-zone.md create mode 100755 modules/docs/src/main/tut/apidocs/zone-model.md create mode 100644 modules/docs/src/main/tut/img/large-denis-logo-crop.png create mode 100644 modules/docs/src/main/tut/index.md create mode 100644 modules/portal/.gitignore create mode 100644 modules/portal/Gruntfile.js create mode 100644 modules/portal/README.md create mode 100644 modules/portal/app/Module.scala create mode 100644 modules/portal/app/controllers/ChangeLogStore.scala create mode 100644 modules/portal/app/controllers/FrontendController.scala create mode 100644 modules/portal/app/controllers/LdapAuthenticator.scala create mode 100644 modules/portal/app/controllers/Settings.scala create mode 100644 modules/portal/app/controllers/UserAccountAccessor.scala create mode 100644 modules/portal/app/controllers/UserAccountStore.scala create mode 100644 modules/portal/app/controllers/VinylDNS.scala create mode 100644 modules/portal/app/controllers/datastores/DynamoDBChangeLogStore.scala create mode 100644 modules/portal/app/controllers/datastores/DynamoDBUserAccountStore.scala create mode 100644 modules/portal/app/controllers/datastores/InMemoryChangeLogStore.scala create mode 100644 modules/portal/app/controllers/datastores/InMemoryUserAccountStore.scala create mode 100644 modules/portal/app/models/CustomLinks.scala create mode 100644 modules/portal/app/models/UserAccount.scala create mode 100644 modules/portal/app/models/VinylRequest.scala create mode 100644 modules/portal/app/views/batchChanges/batchChangeDetail.scala.html create mode 100644 modules/portal/app/views/batchChanges/batchChangeNew.scala.html create mode 100644 modules/portal/app/views/batchChanges/batchChanges.scala.html create mode 100644 modules/portal/app/views/groups/groupDetail.scala.html create mode 100644 modules/portal/app/views/groups/groups.scala.html create mode 100644 modules/portal/app/views/header.scala.html create mode 100644 modules/portal/app/views/login.scala.html create mode 100644 modules/portal/app/views/main.scala.html create mode 100644 modules/portal/app/views/zones/zoneDetail.scala.html create mode 100644 modules/portal/app/views/zones/zoneTabs/changeHistory.scala.html create mode 100644 modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html create mode 100644 modules/portal/app/views/zones/zoneTabs/manageZone.scala.html create mode 100644 modules/portal/app/views/zones/zones.scala.html create mode 100644 modules/portal/conf/application-test.conf create mode 100644 modules/portal/conf/application.conf create mode 100644 modules/portal/conf/logback.xml create mode 100644 modules/portal/conf/routes create mode 100644 modules/portal/karma.conf.js create mode 100644 modules/portal/package.json create mode 100755 modules/portal/prepare-portal.sh create mode 100644 modules/portal/public/app.js create mode 100644 modules/portal/public/css/theme-overrides.css create mode 100644 modules/portal/public/css/vinyldns.css create mode 100644 modules/portal/public/images/email.svg create mode 100644 modules/portal/public/images/favicon.ico create mode 100644 modules/portal/public/images/vinyldns-full-logo-light.png create mode 100644 modules/portal/public/images/vinyldns-logo.png create mode 100644 modules/portal/public/images/vinyldns-portal.png create mode 100644 modules/portal/public/js/custom.js create mode 100644 modules/portal/public/lib/batch-change/batch-change-detail.controller.js create mode 100644 modules/portal/public/lib/batch-change/batch-change-new.controller.js create mode 100644 modules/portal/public/lib/batch-change/batch-change.directive.js create mode 100644 modules/portal/public/lib/batch-change/batch-change.module.js create mode 100644 modules/portal/public/lib/batch-change/batch-change.service.js create mode 100644 modules/portal/public/lib/batch-change/batch-change.spec.js create mode 100644 modules/portal/public/lib/batch-change/batch-changes.controller.js create mode 100644 modules/portal/public/lib/constants.js create mode 100644 modules/portal/public/lib/controllers/controller.groups.js create mode 100644 modules/portal/public/lib/controllers/controller.groups.spec.js create mode 100644 modules/portal/public/lib/controllers/controller.manageZones.js create mode 100644 modules/portal/public/lib/controllers/controller.manageZones.spec.js create mode 100644 modules/portal/public/lib/controllers/controller.membership.js create mode 100644 modules/portal/public/lib/controllers/controller.membership.spec.js create mode 100644 modules/portal/public/lib/controllers/controller.records.js create mode 100644 modules/portal/public/lib/controllers/controller.records.spec.js create mode 100644 modules/portal/public/lib/controllers/controller.zones.js create mode 100644 modules/portal/public/lib/controllers/controller.zones.spec.js create mode 100644 modules/portal/public/lib/controllers/controllers.js create mode 100644 modules/portal/public/lib/directives/directives.js create mode 100644 modules/portal/public/lib/directives/directives.modals.body.js create mode 100644 modules/portal/public/lib/directives/directives.modals.element.js create mode 100644 modules/portal/public/lib/directives/directives.modals.footer.js create mode 100644 modules/portal/public/lib/directives/directives.modals.invalid.js create mode 100644 modules/portal/public/lib/directives/directives.modals.js create mode 100644 modules/portal/public/lib/directives/directives.modals.modal.js create mode 100644 modules/portal/public/lib/directives/directives.modals.record.js create mode 100644 modules/portal/public/lib/directives/directives.modals.zoneconnection.js create mode 100644 modules/portal/public/lib/directives/directives.notifications.js create mode 100644 modules/portal/public/lib/directives/directives.validations.js create mode 100644 modules/portal/public/lib/directives/directives.validations.zones.js create mode 100644 modules/portal/public/lib/services/groups/service.groups.js create mode 100644 modules/portal/public/lib/services/groups/service.groups.spec.js create mode 100644 modules/portal/public/lib/services/paging/service.paging.js create mode 100644 modules/portal/public/lib/services/paging/service.paging.spec.js create mode 100644 modules/portal/public/lib/services/profile/service.profile.js create mode 100644 modules/portal/public/lib/services/profile/service.profile.spec.js create mode 100644 modules/portal/public/lib/services/records/service.records.js create mode 100644 modules/portal/public/lib/services/records/service.records.spec.js create mode 100644 modules/portal/public/lib/services/services.js create mode 100644 modules/portal/public/lib/services/utility/service.utility.js create mode 100644 modules/portal/public/lib/services/utility/service.utility.spec.js create mode 100644 modules/portal/public/lib/services/zones/service.zones.js create mode 100644 modules/portal/public/lib/services/zones/service.zones.spec.js create mode 100644 modules/portal/public/mocks/mockARecord.json create mode 100644 modules/portal/public/mocks/mockARecordSubmit.json create mode 100644 modules/portal/public/mocks/mockBadARecordSubmit.json create mode 100644 modules/portal/public/mocks/mockBadGroupGetMemberList.json create mode 100644 modules/portal/public/mocks/mockBadZoneSubmit.json create mode 100644 modules/portal/public/mocks/mockEmptyGroupList.json create mode 100644 modules/portal/public/mocks/mockGroup.json create mode 100644 modules/portal/public/mocks/mockGroupBadSubmit.json create mode 100644 modules/portal/public/mocks/mockGroupExistingSubmit.json create mode 100644 modules/portal/public/mocks/mockGroupGetMemberList.json create mode 100644 modules/portal/public/mocks/mockGroupList.json create mode 100644 modules/portal/public/mocks/mockGroupListChanges.json create mode 100644 modules/portal/public/mocks/mockGroupSubmit.json create mode 100644 modules/portal/public/mocks/mockHistory.json create mode 100644 modules/portal/public/mocks/mockUserData.json create mode 100644 modules/portal/public/mocks/mockZone.json create mode 100644 modules/portal/public/mocks/mockZoneSubmit.json create mode 100644 modules/portal/public/templates/modal-body.html create mode 100644 modules/portal/public/templates/modal-element.html create mode 100644 modules/portal/public/templates/modal-footer.html create mode 100644 modules/portal/public/templates/modal-invalid.html create mode 100644 modules/portal/public/templates/modal.html create mode 100644 modules/portal/public/templates/notification.html create mode 100644 modules/portal/public/templates/record-modal.html create mode 100644 modules/portal/public/templates/zoneconnection-modal.html create mode 100755 modules/portal/run_all_tests.sh create mode 100644 modules/portal/test/controllers/FrontendControllerSpec.scala create mode 100644 modules/portal/test/controllers/LdapAuthenticatorSpec.scala create mode 100644 modules/portal/test/controllers/UserAccountAccessorSpec.scala create mode 100644 modules/portal/test/controllers/VinylDNSSpec.scala create mode 100644 modules/portal/test/controllers/datastores/DynamoDBChangeLogStoreSpec.scala create mode 100644 modules/portal/test/controllers/datastores/DynamoDBUserAccountStoreSpec.scala create mode 100644 modules/portal/test/controllers/datastores/InMemoryChangeLogStoreSpec.scala create mode 100644 modules/portal/test/controllers/datastores/InMemoryUserAccountStoreSpec.scala create mode 100644 modules/portal/test/models/CustomLinksSpec.scala create mode 100644 modules/portal/test/models/UserAccountSpec.scala create mode 100644 modules/portal/test/models/VinylRequestSpec.scala create mode 100644 project/CompilerOptions.scala create mode 100644 project/Dependencies.scala create mode 100644 project/Resolvers.scala create mode 100644 project/assembly.sbt create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 scalastyle-config.xml create mode 100644 scalastyle-test-config.xml create mode 100644 version.sbt diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000000..cb91fc143f --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,45 @@ +# Authors + +This project would not be possible without the generous contributions of many people. +Thank you! If you have contributed in any way, but do not see your name here, please open a PR to add yourself! + +## Maintainers +- Paul Cleary +- Nima Eskandary +- Michael Ly +- Rebecca Star +- Britney Wright + +## Tool Maintainers +- Mike Ball: vinyldns-cli, vinyldns-terraform +- Nathan Pierce: vinyldns-ruby + +## DNS SMEs +- Joe Crowe +- David Back +- Hong Ye + +## Contributors +- Tommy Barker +- Robert Barrimond +- Charles Bitter +- Maulon Byron +- Peter Cline +- Kemar Cockburn +- Luke Cori +- Jearvon Dharrie +- Daniel Jin +- Krista Khare +- Patrick Lee +- Sheree Liu +- Deepak Mohanakrishnan +- Joshulyne Park +- Sriram Ramakrishnan +- Khalid Reid +- Trent Schmidt +- Ghafar Shah +- Jess Stodola +- Jim Wakeman +- Fei Wan +- Peter Willis +- Andrew Wang diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..519e24176e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# VinylDNS Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at vinyldns-core@googlegroups.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..f7fa6333cd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,173 @@ +# Contributing to VinylDNS +The following are a set of guidelines for contributing to VinylDNS and its associated repositories. + +## Table of Contents +- [Code of Conduct](#code-of-conduct) +- [Issues](#issues) +- [Making Contributions](#making-contributions) +- [Style Guide](#style-guide) +- [Testing](#testing) +- [License Header Check](#license-header-check) +- [Release Management](#release-management) + +## Code of Conduct +This project and everyone participating in it are governed by the [VinylDNS Code Of Conduct](CODE_OF_CONDUCT.md). By +participating, you agree to this Code. Please report any violations to the code of conduct to vinyldns-core@googlegroups.com. + +## Issues +If you would like to contribute to VinylDNS, you can look through `beginner` and `help-wanted` issues. We keep a list +of these issues around to encourage participation in building the platform. In the issue list, you can chose "Labels" and +choose a specific label to narrow down the issues to review. + +* **Beginner issues**: only require a few lines of code to complete, rather isolated to one or two files. A good way +to get through changing and testing your code, and meet everyone! +* **Help wanted issues**: these are more involved than beginner issues, are items that tend to come near the top of our backlog but not necessarily in the current development stream. + +Besides those issues, you can sort the issue list by number of comments to find one that maybe of interest. You do +_not_ have to limit yourself to _only_ "beginner" or "help-wanted" issues. + +Before choosing an issue, see if anyone is assigned or has indicated they are working on it (either in comment or via PR). +You can work on the issue by reviewing the PR or asking where they are at; otherwise, it doesn't make sense to duplicate +work that is already in-progress. + +## Making Contributions +### Submitting a Code Contribution +We follow the standard *GitHub Flow* for taking code contributions. The following is the process typically followed: + +1 - Create a fork of the repository that you want to contribute code to +1 - Clone your forked repository to your local machine +1 - In your local machine, add a remote to the "main" repository, we call this "upstream" by running +`git remote add upstream https://github.com/vinyldns/vinyldns.git`. Note: you can also use `ssh` instead of `https` +1 - Create a local branch for your work `git checkout -b your-user-name/user-branch-name`. Add whatever your GitHub +user name is before whatever you want your branch to be. +1 - Begin working on your local branch +1 - Make sure you run all builds before posting a PR! It's faster to run everything locally rather than waiting for +the build server to complete its job. See [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md) for information on local development +1 - When you are ready to contribute your code, run `git push origin your-user-name/user-branch-name` to push your changes +to your _own fork_. +1 - Go to the [VinylDNS main repository](https://github.com/vinyldns/vinyldns.git) (or whatever repo you are contributing to) +and you will see your change waiting and a link to "Create a PR". Click the link to create a PR. +1 - You will receive comments on your PR. Use the PR as a dialog on your changes. + +### Commit Messages +* Limit the first line to 72 characters or fewer. +* Use the present tense ("Add validation" not "Added validation"). +* Use the imperative mood ("Move database call" not "Moves database call"). +* Reference issues and other pull requests liberally after the first line. Use [GitHub Auto Linking](https://help.github.com/articles/autolinked-references-and-urls/) +to link your PR to other issues. _Note: This is essential, otherwise we may not know what issue a PR is created for_ +* Use markdown syntax as much as you want + +### Modifying your Pull Requests +Often times, you will need to make revisions to your PRs that you submit. This is part of the standard process of code +review. There are different ways that you can make revisions, but the following process is pretty standard. + +1 - Sync with upstream first. `git checkout master && git fetch upstream && git rebase upstream master && git push origin master` +1 - Checkout your branch on your local `git checkout your-user-name/user-branch-name` +1 - Sync your branch with latest `git rebase master`. Note: If you have merge conflicts, you will have to resolve them +1 - Revise your PR, making changes recommended in the comments / code review +1 - When all tests pass, `git push origin your-user-name/user-branch-name` to revise your commit. GitHub automatically +recognizes the update and will re-run verification on your PR! + +### Merging your Pull Request +Once your PR is approved, one of the maintainers will merge your request for you. If you are a maintainer, you can +merge your PR once you have the approval of at least 2 other maintainers. + +## Style Guides +### Python Style Guide +* Use snake case for everything except classes. `this_is_snake_case`; `thisIsNotSnakeCaseDoNotDoThis` + +## Testing +For specific steps to run the tests see the [Testing](BUILDING.md#testing) section of the Building guide. + +### Python for Testing +We use [pytest](https://docs.pytest.org/en/latest/) for python tests. It is helpful that you browse the documentation +so that you are familiar with pytest and how our functional tests operate. + +We also use [PyHamcrest](https://pyhamcrest.readthedocs.io/en/release-1.8/) for matchers in order to write easy +to read tests. Please browse that documentation as well so that you are familiar with the different matchers +for PyHamcrest. There aren't a lot, so it should be quick. + +Want to become a super star? [Write custom matchers!](https://pyhamcrest.readthedocs.io/en/release-1.8/custom_matchers/) + +### Python Setup +We use python for our functional tests exclusively in this project. You can find all python code under the +`functional_test` directory. + +In that directory are a few important files for you to be familiar with: + +* vinyl_client.py - this provides the interface to the VinylDNS api. It handles signing the request for you, as well +as building and executing the requests, and giving you back valid responses. For all new API endpoints, there should +be a corresponding function in the vinyl_client +* utils.py - provides general use functions that can be used anywhere in your tests. Feel free to contribute new +functions here when you see repetition in the code + +Functional tests run on every build, and are designed to work _in every environment_. That means locally, in docker, +and in production environments. + +The functional tests that we run live in `functional_test/live_tests` directory. In there, we have directories / modules +for different areas of the application. + +* membership - for managing groups and users +* recordsets - for managing record sets +* zones - for managing zones +* internal - for internal endpoints (not intended for public consumption) +* batch - for managing batch updates + +### Functional Test Context +Our func tests use pytest contexts. There is a main test context that lives in `shared_zone_test_context.py` +that creates and tears down a shared test context used by many functional tests. The +beauty of pytest is that it will ensure that the test context is stood up exactly once, then all individual tests +that use the context are called using that same context. + +The shared test context sets up several things that can be reused: + +1. An ok user and group +1. A dummy user and group - a separate user and group helpful for tesing access controls and authorization +1. An ok zone accessible only by the ok user and ok group +1. A dummy zone accessible only by the dummy user and dummy group +1. An IPv6 reverse zone +1. A normal IPv4 reverse zone +1. A classless IPv4 reverse zone +1. A parent zone that has child zones - used for testing NS record management and zone delegations + +### Really Important Test Context Rules! + +1. Try to use the `shared_zone_test_context` whenever possible! This reduces the time +it takes to run functional tests (which is in minutes). +1. Limit changes to users, groups, and zones in the shared test context, as doing so could impact downstream tests +1. If you do modify any entities in the shared zone context, roll those back when your function completes! + +## License Header Check + +### API +VinylDNS is configured with [sbt-header](https://github.com/sbt/sbt-header). All existing scala files have the appropriate +header. You can check for headers in `sbt` with: + +```bash +> ;headerCheck;test:headerCheck;it:headerCheck +``` + +If you add a new file, you can add the appropriate header in `sbt` with: +```bash +> ;headerCreate;test:headerCreate;it:headerCreate +``` + +### Portal +>You can check for headers in `sbt` with: +``` +project portal +;headerCheck;test:headerCheck;checkJsHeaders +``` + +>You can create headers in `sbt` with: +``` +project portal +;headerCreate;test:headerCreate;createJsHeaders +``` + +## Release Management +As an overview, we release on a regular schedule roughly once per month. At any time, you can see the following releases scheduled using Milestones in GitHub. + +* - for example, 0.9.8. This constitutes the current work that is in-flight +* - for example, 0.9.9. These are the issues pegged for the _next_ release to be worked on +* Backlog - These are the issues designated to be worked on in the not too distant future. diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md new file mode 100644 index 0000000000..0622c2da43 --- /dev/null +++ b/DEVELOPER_GUIDE.md @@ -0,0 +1,251 @@ +# Getting Started + +## Table of Contents +- [Project Structure](#project-structure) +- [Developer Requirements](#developer-requirements) +- [Docker](#docker-setup) +- [Configuration](#configuration) +- [Starting the API Server Locally](#starting-the-api-server-locally) +- [Starting the Portal Locally](#starting-the-portal-locally) +- [Testing](#testing) +- [Handy Scripts](#handy-scripts) + +## Project Structure +Make sure that you have the requirements installed before proceeding. + +The main codebase is a multi-module Scala project with multiple sub-modules. To start working with the project, +from the root directory run `sbt`. Most of the code can be found in the `modules` directory. +The following modules are present: + +* `root` - this is the parent project, if you run tasks here, it will run against all sub-modules +* `api` - the engine behind VinylDNS. Has the REST API that all things interact with. +* `core` - contains code applicable across modules +* `portal` - the web user interface for VinylDNS +* `docs` - the API Documentation for VinylDNS + +### VinylDNS API +The API is the RESTful API for interacting with VinylDNS. The code is found in `modules/api`. The following technologies are used: + +* [Akka HTTP](https://doc.akka.io/docs/akka-http/current/) - Used primarily for REST and HTTP calls. We migrated +code from Spray.io, so Akka HTTP was a rather seamless upgrade +* [FS2](https://functional-streams-for-scala.github.io/fs2/) - Used for backend change processing off of message queues. +FS2 has back-pressure built in, and gives us tools like throttling and concurrency. +* [Cats Effect](https://typelevel.org/cats-effect/) - We are currently migrating away from `Future` as our primary type +and towards cats effect IO. Hopefully, one day, all the things will be using IO. +* [Cats](https://typelevel.org/cats) - Used for functional programming. There is presently a hybrid of somethings +scalaz and other things cats. We are migrating away from scalaz, so when building new code prefer cats if possible. +* [PureConfig](https://pureconfig.github.io/) - For loading configuration values. We are currently migrating to +use PureConfig everywhere. Not all the places use it yet. + +The API has the following dependencies: +* MySQL - the SQL database that houses zone data +* DynamoDB - where all of the other data is stored +* SQS - for managing concurrent updates and enabling high-availability +* Bind9 - for testing integration with a real DNS system + +#### The API Code +The API code can be found in `modules/api` + +* `functional_test` - contains the python black box / regression tests +* `src/it` - integration tests +* `src/main` - the main source code +* `src/test` - unit tests +* `src/universal` - items that are packaged in the docker image for the VinylDNS API + +The package structure for the source code follows: + +* `vinyldns.api.domain` - contains the core front-end logic. This includes things like the application services, +repository interfaces, domain model, validations, and business rules. +* `vinyldns.api.engine` - the back-end processing engine. This is where we process commands including record changes, +zone changes, and zone syncs. +* `vinyldns.api.protobuf` - marshalling and unmarshalling to and from protobuf to types in our system +* `vinyldns.api.repository` - repository implementations live here +* `vinyldns.api.route` - http endpoints + +### VinylDNS Portal +The Portal project (found in `modules/portal`) is the user interface for VinylDNS. The project is built +using: +* [Play Framework](https://www.playframework.com/documentation/2.6.x/Home) +* [AngularJS](https://angularjs.org/) + +Tne portal is _mostly_ a shim around the API. Most actions in the user interface and translated into API calls. + +The features that the Portal provides that are not in the API include: +* Authentication against LDAP +* Creation of users - when a user logs in for the first time, VinylDNS automatically creates a user for them in the +database with their LDAP information. + +## Developer Requirements +- sbt +- Java 8 +- Python 2.7 +- virtualenv +- docker +- wget +- Protobuf 2.6.1 + +### Installing Protobuf on a Mac +The protocol buffer library is located at `https://github.com/sbt/sbt-protobuf`, we currently have it set to v0.5.2, which can only support up to protobuf v2.6.1 + +Run `protoc --version`, if it is not 2.6.1, then + +1. Note that on Mac OS, `brew install protobuf` will install a version too new to use with this project, if you have protobuf installed through brew, then run `brew uninstall protobuf` +1. To install protobuf v2.6.1, go to https://github.com/google/protobuf/releases/tag/v2.6.1, and download `protobuf-2.6.1.tar.gz` +1. Run the following commands to extract the tar, cd into it, and configure/install: + ``` + $ cd ~/Downloads; tar -zxvf protobuf-2.6.1.tar.gz; cd protobuf-2.6.1 + $ ./configure + $ make + $ make check + $ sudo make install + + ``` +1. Finally, run `protoc --version` to confirm you are on v2.6.1 + +## Docker +Be sure to install the latest version of [docker](https://docs.docker.com/). You must have docker running in order to work with VinylDNS on your machine. +Be sure to start it up if it is not running before moving further. + +### How to use the Docker Image +#### Starting a vinyldns-api server instance +VinylDNS depends on several dependencies including mysql, sqs, dynamodb and a DNS server. These can be passed in as +environment variables, or you can override the config file with your own settings. + +#### Environment variables +1. `MYSQL_ADDRESS` - the IP address of the mysql server; defaults to `vinyldns-mysql` assuming a docker compose setup +1. `MYSQL_PORT` - the port of the mysql server; defaults to 3306 + +#### Volume Mounts +vinyldns exposes volumes that allow the user to customize the runtime. Those mounts include: + +* `/opt/docker/lib_extra` - place here additional jar files that need to be loaded into the classpath when the application starts up. +This is used for "plugins" that are proprietary or not part of the standard build. All jar files here will be placed on the class path. +* `/opt/docker/conf` - place an `application.conf` file here with your own custom settings. This can be easier than passing in environment +variables. + +#### Ports +vinyldns only exposes port 9000 for HTTP access to all endpoints + +#### Starting a vinyldns installation locally in docker +There is a handy docker-compose file for spinning up the production docker image on your local under `docker/docker-compose-build.yml` + +From the root directory run... + +``` +> docker-compose -f ./docker/docker-compose-build.yml up -d +``` + +This will startup all the dependencies as well as the api server. Once the api server is running, you can verify it is +up by running the following `curl -v http://localhost:9000/status` + +To stop the local setup, run `./bin/stop-all-docker-containers.sh` from the project root. + +#### Validating everything +VinylDNS comes with a build script `./build.sh` that validates, verifies, and runs functional tests. Note: This +takes a while to run, and typically is only necessary if you want to simulate the same process that runs on the build +servers + +When functional tests run, you will see a lot of output intermingled together across the various containers. You can view only the output +of the functional tests at `target/vinyldns-functest.log`. If you want to see the docker log output from any one +container, you can view them after the tests complete at: + +* `target/vinyldns-api.log` - the api server logs +* `target/vinyldns-bind9.log` - the bind9 DNS server logs +* `target/vinyldns-dynamodb.log` - the DynamoDB server logs +* `target/vinyldns-elasticmq.log` - the ElasticMQ (SQS) server logs +* `target/vinyldns-functest.log` - the output of running the functional tests +* `target/vinyldns-mysql.log` - the MySQL server logs + +When the func tests complete, the entire docker setup will be automatically torn down. + +## Starting the API server locally +To start the API for integration, functional, or portal testing. Start up sbt by running `sbt` from the root directory. +* `project api` to change the sbt project to the api +* `dockerComposeUp` to spin up the dependencies on your machine. +* `reStart` to start up the API server +* Wait until you see the message `VINYLDNS SERVER STARTED SUCCESSFULLY` before working with the server +* To stop the VinylDNS server, run `reStop` from the api project +* To stop the dependent docker containers, run `dockerComposeStop` from the api project + +## Starting the Portal locally +To run the portal locally, you _first_ have to start up the VinylDNS API Server (see instructions above). Once +that is done, in the same `sbt` session or a different one, go to `project portal` and then execute `;preparePortal; run`. + +### Testing the portal against your own LDAP directory +Often, it is valuable to test locally hitting your own LDAP directory. This is possible to do, just take care when +following these steps as to not accidentally check in secrets or your own environment information in future PRs. + +1. Create a file `modules/portal/conf/local.conf`. This file is added to `.gitignore` so it should not be committed +1. Configure your own LDAP settings in local.conf. See the LDAP section of `modules/portal/conf/application.conf` for the +expected format. Be sure to set `portal.test_login = false` in that file to override the test setting +1. If you need SSL certs, you will need to create a java keystore that holds your SSL certificates. The portal only +_reads_ from the trust store, so you do not need to pass in the password to the app. +1. Put the trust store in `modules/portal/private` directory. It is also added to .gitignore to prevent you from +accidentally committing it. +1. Start `sbt` in a separate terminal by running `sbt -Djavax.net.ssl.trustStore="modules/portal/private/trustStore.jks"` +1. Go to `project portal` and type `;preparePortal;run` to start up the portal +1. You can now login using your own LDAP repository going to http://localhost:9001/login + +## Configuration +Configuration of the application is done using [Typesafe Config](https://github.com/typesafehub/config). + +* `reference.conf` contains the _default_ configuration values. +* `application.conf` contains environment specific overrides of the defaults + +## Testing +### Unit Tests +1. First, start up your scala build tool: `sbt`. I usually do a *clean* immediately after starting. +1. (Optionally) Go to the project you want to work on, for example `project api` for the api; `project portal` for the portal. +1. Run _all_ unit tests by just running `test` +1. Run an individual unit test by running `testOnly *MySpec` +1. If you are working on a unit test and production code at the same time, use `~` that automatically background compiles for you! +`~testOnly *MySpec` + +### Integration Tests +Integration tests are used to test integration with _real_ dependent services. We use docker to spin up those +backend services for integration test development. + +1. Integration tests are currently only in the `api` module. Go to the module in sbt `project api` +1. Type `dockerComposeUp` to start up dependent background services +1. Run all integration tests by typing `it:test`. +1. Run an individual integration test by typing `it:testOnly *MyIntegrationSpec` +1. You can background compile as well if working on a single spec by using `~it:testOnly *MyIntegrationSpec` + +### Functional Tests +When adding new features, you will often need to write new functional tests that black box / regression test the +API. We have over 350 (and growing) automated regression tests. The API functional tests are written in Python +and live under `modules/api/functional_test`. + +To run functional tests, make sure that you have started the api server (directions above). Then outside of sbt, `cd modules/api/functional_test`. + +### Managing Test Zone Files +When functional tests are run, we spin up several docker containers. One of the docker containers is a Bind9 DNS +server. If you need to add or modify the test DNS zone files, you can find them in +`docker/bind9/zones` + +## Handy Scripts +### Start up a complete local API server +`bin/docker-up-api-server.sh` - this will build vinyl (if not built) and then start up an api server and all dependencies + +The following ports and services are available: + +- mysql - 3306 +- dynamodb - 19000 +- bind9 - 19001 +- sqs - 9324 +- api server (main vinyl backend app) - 9000 + +To kill the environment, run `bin/stop-all-docker-containers.sh` + +### Kill all docker containers +`bin/stop-all-docker-containers` - sometimes, you can have orphaned docker containers hanging out. Run this +script to tear everything down. Note: It will stop ALL docker containers on the current machine! + +### Start up a DNS server +`bin/docker-up-dns-server.sh` - fires up a DNS server. Sometimes, especially when developing func tests, you want +to quickly see how new test zones / records behave without having to fire up an entire environment. This script +fires up _only_ the dns server with our test zones. The DNS server is accessible locally on port 19001. + +### Publish the API docker image +`bin/docker-publish-api.sh` - publishes the API docker image. You must be logged into the repo you are publishing to +using `docker login`, or create a file in `~/.ivy/.dockerCredentials` that has your credentials in it following the format defined in https://www.scala-sbt.org/1.x/docs/Publishing.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..29064f28b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Comcast Cable Communications Management, LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md index c8a7d54d32..90f9d52223 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,96 @@ -# vinyldns -DNS Management System +[![Join the chat at https://gitter.im/vinyldns/Lobby](https://badges.gitter.im/vinyldns/vinyldns.svg)](https://gitter.im/vinyldns/Lobby) + +# VinylDNS + +VinylDNS is a vendor agnostic front-end for managing self-service DNS across your DNS systems. +The platform provides fine-grained access controls, auditing of all changes, a self-service user interface, +secure REST based API, and integration with infrastructure automation tools like Ansible and Terraform. +It is designed to integrate with your existing DNS infrastructure, and provides extensibility to fit your installation. + +Currently, VinylDNS supports: +* Connecting to existing DNS Zones +* Creating, updating, deleting DNS Records +* Working with forward and reverse zones +* Working with IP4 and IP6 records +* Governing access with fine-grained controls at the record and zone level +* Bulk updating of DNS records across zones + +VinylDNS helps secure DNS management via: +* AWS Sig4 signing of all messages to ensure that the message that was sent was not altered in transit +* Throttling of DNS updates to rate limit concurrent updates against your DNS systems +* Encrypting user secrets and TSIG keys at rest and in-transit +* Recording every change made to DNS records and zones + +Integration is simple with first-class language support including: +* java +* ruby +* python +* go-lang + +## Table of Contents +- [Roadmap](#roadmap) +- [Code of Conduct](#code-of-conduct) +- [Developer Guide](#developer-guide) +- [Project Layout](#project-layout) +- [Contributing](#contributing) +- [Contact](#contact) +- [Maintainers and Contributors](#maintainers-and-contributors) +- [Credits](#credits) + +## Roadmap +See [ROADMAP.md](ROADMAP.md) for the future plans for VinylDNS. + +## Code of Conduct +This project and everyone participating in it are governed by the [VinylDNS Code Of Conduct](CODE_OF_CONDUCT.md). By +participating, you agree to this Code. Please report any violations to the code of conduct to vinyldns-core@googlegroups.com. + +## Developer Guide +### Requirements +- sbt +- Java 8 +- Python 2.7 +- virtualenv +- docker +- wget +- Protobuf 2.6.1 + +See [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md) for instructions on setting up VinylDNS locally. + +## Project Layout +* [API](modules/api): the API is the main engine for all of VinylDNS. This is the most active area of the codebase, as everything else typically just funnels through +the API. More detail on the API can be provided below. +* [Portal](modules/portal): The portal is a user interface wrapper around the API. Most of the business rules, logic, and processing can be found in the API. The +_only_ feature in the portal not found in the API is creation of users and user authentication. +* [Documentation](modules/docs): The documentation is primarily in support of the API. + +For more details see the [project structure](DEVELOPER_GUIDE.md#project-structure) in the Developer Guide. + +## Contributing +See the [Contributing Guide](CONTRIBUTING.md). + +## Contact +- [Gitter](https://gitter.im/vinyldns/Lobby) +- [Mailing List](https://groups.google.com/forum/#!forum/vinyldns) +- If you have any security concerns please contact the maintainers directly vinyldns-core@googlegroups.com + +## Maintainers and Contributors +The current maintainers (people who can merge pull requests) are: +- Paul Cleary +- Michael Ly +- Rebecca Star +- Britney Wright + +See [AUTHORS.md](AUTHORS.md) for the full list of contributors to VinylDNS. + +## Credits +VinylDNS would not be possible without the help of many other pieces of open source software. Thank you open source world! + +Initial development of DynamoDBHelper done by [Roland Kuhn](https://github.com/rkuhn) from https://github.com/akka/akka-persistence-dynamodb/blob/8d7495821faef754d97759f0d3d35ed18fc17cc7/src/main/scala/akka/persistence/dynamodb/journal/DynamoDBHelper.scala + +Given the Apache 2.0 license of VinylDNS, we specifically want to call out the following libraries and their corresponding licenses shown below. +- [logback-classic](https://github.com/qos-ch/logback) - [Eclipse Public License 1.0](https://www.eclipse.org/legal/epl-v10.html) +- [logback-core](https://github.com/qos-ch/logback) - [Eclipse Public License 1.0](https://www.eclipse.org/legal/epl-v10.html) +- [h2 database](http://h2database.com) - [Mozilla Public License, version 2.0](https://www.mozilla.org/MPL/2.0/) +- [pureconfig](https://github.com/pureconfig/pureconfig) - [Mozilla Public License, version 2.0](https://www.mozilla.org/MPL/2.0/) +- [pureconfig-macros](https://github.com/pureconfig/pureconfig) - [Mozilla Public License, version 2.0](https://www.mozilla.org/MPL/2.0/) +- [junit](https://junit.org/junit4/) - [Eclipse Public License 1.0](https://www.eclipse.org/legal/epl-v10.html) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..a2fee1658c --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,29 @@ +# Roadmap + +The Roadmap captures the plans for VinylDNS. There are a few high-level features that are planned for active development: + +1. DNS SEC - There is no first-class support for DNS SEC. That feature set is being defined. +1. Shared Zones - IP space and large common zones are cumbersome to manage using fine-grained ACL rules. Shared zones +enable self-service management of records via a record ownership model for access controls. Record ownership assigns +a group as the owner of the record to restrict who can modify that record. +1. Zone Management - Presently VinylDNS _connects to existing zones_ for management. Zone Management will allow users +to create and manage zones in the authoritative systems themselves. +1. Record meta data - VinylDNS will allow the "tagging" of DNS records with arbitrary key-value pairs + +In addition to large feature initiatives, we will be looking to improve how VinylDNS is operated. The current +installation requires the following components: + +* At least one VinylDNS API server +* At least one VinylDNS portal server +* AWS DynamoDB +* MySQL Database +* AWS SQS Message Queues + +We would like to: +* Run entirely in a single database without MySQL. This may be necessary as the query requirements of VinylDNS are +exceeding the capabilities of DynamoDB. +* Support alternative message queues, for example RabbitMQ +* Support additional databases, including PostgreSQL and MongoDB +* Support additional languages +* Support additional automation tools +* A new user interface (the existing portal is built using AngularJS, there are new and better ways to UI these days) diff --git a/bin/add-license-headers.sh b/bin/add-license-headers.sh new file mode 100755 index 0000000000..7cac5e5165 --- /dev/null +++ b/bin/add-license-headers.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +TARGET_DIR="" +FIND_COMMAND_OPTS="" +SUPPORTED_FILE_TYPE="*" # Default is to choose all file types + +EXIT_CODE=0 + +# Global flags +CHECK_ONLY_FLAG=0 +HELP_FLAG=0 +VERBOSE_FLAG=0 + +LICENSE_TEXT="/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the \"License\"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an \"AS IS\" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */" + +print_usage () { + echo -e "Description: Prepends license/header text by recursively traversing for files.\n" + echo -e "Usage: add-license-headers.sh [-d= | --directory=] [-f= | --file-type=] [-h | --help] [-v | --verbose]\n" + echo -e "\t-c|--check-only \tEnables check-only mode. Does not actually modify files, but will return an error (exit code 1) if there are files that are missing headers." + echo -e "\t-d|--directory \tSet target directory to recursively search for files to prepend headers." + echo -e "\t-f|--file-type \tAdd case-insensitive, supported file type (eg. txt). Supports single call. If no file types are specified, all will be included by default." + echo -e "\t-v|--verbose \tEnable verbose mode." + echo -e "\t-h|--help \tPrint out usage information." +} + +# Process command line arguments + +for i in "$@" +do + case "$i" in + -c|--check-only) + CHECK_ONLY_FLAG=1 + shift + ;; + + -d=*|--directory=*) + TARGET_DIR="${i#*=}" + shift + ;; + + -f=*|--file-types=*) + SUPPORTED_FILE_TYPE="*.${i#*=}" + shift + ;; + + -v|--verbose) + VERBOSE_FLAG=1 + shift + ;; + + -h|--help) + HELP_FLAG=1 + shift + ;; + esac +done + +# Perform preliminary checks + +if [ "$HELP_FLAG" = 1 ]; then + print_usage + exit "$EXIT_CODE" +fi + +if [ -z "$TARGET_DIR" ]; then + echo "Target directory is required but not specified." + EXIT_CODE=1 +elif [ ! -d "$TARGET_DIR" ]; then + echo "Specified target directory \"$TARGET_DIR\" does not exist." + EXIT_CODE=1 +fi + +if [ "$EXIT_CODE" = 1 ]; then + echo "" + print_usage + echo "Aborting program due to errors." + exit "$EXIT_CODE" +fi + +LICENSE_TEXT_LINES=$(echo "$LICENSE_TEXT" | awk '{print NR}' | tail -1) + +for file in $(find "$TARGET_DIR" -type f -iname "$SUPPORTED_FILE_TYPE"); do + if [ ! -d "$file" ]; then + STARTING_TEXT=$(head -n "$LICENSE_TEXT_LINES" "$file") + if [[ "$STARTING_TEXT" != "$LICENSE_TEXT" ]]; then + if [ "$CHECK_ONLY_FLAG" = 1 ]; then + EXIT_CODE=1 + echo "$file" + else + if [ "$VERBOSE_FLAG" = 1 ]; then + echo "$file" + fi + $(printf '0i\n'"$LICENSE_TEXT"'\n\n.\nwq\n' | ed -s "$file") + fi + fi + fi +done + +exit "$EXIT_CODE" diff --git a/bin/build.sh b/bin/build.sh new file mode 100755 index 0000000000..eec65e38cc --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +DIR=$( cd $(dirname $0) ; pwd -P ) + +echo "Verifying code..." +#${DIR}/verify.sh + +#step_result=$? +step_result=0 +if [ ${step_result} != 0 ] +then + echo "Failed to verify build!!!" + exit ${step_result} +fi + +echo "Func testing the api..." +${DIR}/func-test-api.sh + +step_result=$? +if [ ${step_result} != 0 ] +then + echo "Failed API func tests!!!" + exit ${step_result} +fi + +echo "Func testing the portal..." +${DIR}/func-test-portal.sh +step_result=$? +if [ ${step_result} != 0 ] +then + echo "Failed Portal func tests!!!" + exit ${step_result} +fi + +exit 0 diff --git a/bin/docker-publish-api.sh b/bin/docker-publish-api.sh new file mode 100755 index 0000000000..80d1282d3c --- /dev/null +++ b/bin/docker-publish-api.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +DIR=$( cd $(dirname $0) ; pwd -P ) + +cd $DIR/../ + +echo "Publishing docker image..." +sbt clean docker:publish +publish_result=$? +cd $DIR +exit ${publish_result} diff --git a/bin/docker-up-api-server.sh b/bin/docker-up-api-server.sh new file mode 100755 index 0000000000..80885b1dff --- /dev/null +++ b/bin/docker-up-api-server.sh @@ -0,0 +1,52 @@ +#!/bin/bash +###################################################################### +# Copies the contents of `docker` into target/scala-2.12 +# to start up dependent services via docker compose. Once +# dependent services are started up, the fat jar built by sbt assembly +# is loaded into a docker container. The api will be available +# by default on port 9000 +###################################################################### + +DIR=$( cd $(dirname $0) ; pwd -P ) +WORK_DIR=$DIR/../target/scala-2.12 +mkdir -p $WORK_DIR + +echo "Copy all docker to the target directory so we can start up properly and the docker context is small..." +cp -af $DIR/../docker $WORK_DIR/ + +echo "Copy the vinyldns.jar to the api docker folder so it is in context..." +if [[ ! -f $DIR/../modules/api/target/scala-2.12/vinyldns.jar ]]; then + echo "vinyldns jar not found, building..." + cd $DIR/../ + sbt api/clean api/assembly + cd $DIR +fi +cp -f $DIR/../modules/api/target/scala-2.12/vinyldns.jar $WORK_DIR/docker/api + +echo "Starting api server and all dependencies in the background..." +docker-compose -f $WORK_DIR/docker/docker-compose-func-test.yml --project-directory $WORK_DIR/docker up --build -d api + +VINYL_URL="http://localhost:9000" +echo "Waiting for API to be ready at ${VINYL_URL} ..." +DATA="" +RETRY=40 +while [ $RETRY -gt 0 ] +do + DATA=$(wget -O - -q -t 1 "${VINYL_URL}/ping") + if [ $? -eq 0 ] + then + echo "Succeeded in connecting to VINYL!" + break + else + echo "Retrying Again" >&2 + + let RETRY-=1 + sleep 1 + + if [ $RETRY -eq 0 ] + then + echo "Exceeded retries waiting for VINYL to be ready, failing" + exit 1 + fi + fi +done diff --git a/bin/docker-up-dns-server.sh b/bin/docker-up-dns-server.sh new file mode 100755 index 0000000000..864a47d0aa --- /dev/null +++ b/bin/docker-up-dns-server.sh @@ -0,0 +1,5 @@ +#!/bin/bash +DIR=$( cd $(dirname $0) ; pwd -P ) + +echo "Starting ONLY the bind9 server. To start an api server use the api server script" +docker-compose -f $DIR/../docker/docker-compose-func-test.yml --project-directory $DIR/../docker up -d bind9 diff --git a/bin/func-test-api.sh b/bin/func-test-api.sh new file mode 100755 index 0000000000..13a2a86a32 --- /dev/null +++ b/bin/func-test-api.sh @@ -0,0 +1,52 @@ +#!/bin/bash +###################################################################### +# Copies the contents of `docker` into target/scala-2.12 +# to start up dependent services via docker compose. Once +# dependent services are started up, the fat jar built by sbt assembly +# is loaded into a docker container. Finally, the func tests run inside +# another docker container +# At the end, we grab all the logs and place them in the target +# directory +###################################################################### + +DIR=$( cd $(dirname $0) ; pwd -P ) +WORK_DIR=$DIR/../target/scala-2.12 +mkdir -p $WORK_DIR + +echo "Cleaning up unused networks..." +docker network prune -f + +echo "Copy all docker to the target directory so we can start up properly and the docker context is small..." +cp -af $DIR/../docker $WORK_DIR/ + +echo "Copy over the functional tests as well as those that are run in a container..." +mkdir -p $WORK_DIR/functest +rsync -av --exclude='.virtualenv' $DIR/../modules/api/functional_test $WORK_DIR/docker/functest + +echo "Copy the vinyldns.jar to the api docker folder so it is in context..." +if [[ ! -f $DIR/../modules/api/target/scala-2.12/vinyldns.jar ]]; then + echo "vinyldns jar not found, building..." + cd $DIR/../ + sbt api/clean api/assembly + cd $DIR +fi +cp -f $DIR/../modules/api/target/scala-2.12/vinyldns.jar $WORK_DIR/docker/api + +echo "Staring docker environment and running func tests..." +docker-compose -f $WORK_DIR/docker/docker-compose-func-test.yml --project-directory $WORK_DIR/docker --log-level ERROR up --build --exit-code-from functest +test_result=$? + +echo "Grabbing the logs..." + +docker logs vinyldns-api > $DIR/../target/vinyldns-api.log 2>/dev/null +docker logs vinyldns-bind9 > $DIR/../target/vinyldns-bind9.log 2>/dev/null +docker logs vinyldns-mysql > $DIR/../target/vinyldns-mysql.log 2>/dev/null +docker logs vinyldns-elasticmq > $DIR/../target/vinyldns-elasticmq.log 2>/dev/null +docker logs vinyldns-dynamodb > $DIR/../target/vinyldns-dynamodb.log 2>/dev/null +docker logs vinyldns-functest > $DIR/../target/vinyldns-functest.log 2>/dev/null + +echo "Cleaning up docker containers..." +$DIR/./stop-all-docker-containers.sh + +echo "Func tests returned result: ${test_result}" +exit ${test_result} diff --git a/bin/func-test-portal.sh b/bin/func-test-portal.sh new file mode 100755 index 0000000000..816828a2fa --- /dev/null +++ b/bin/func-test-portal.sh @@ -0,0 +1,46 @@ +#!/bin/bash +###################################################################### +# Runs e2e tests against the portal +###################################################################### + +DIR=$( cd $(dirname $0) ; pwd -P ) +WORK_DIR=$DIR/../modules/portal + +function check_for() { + which $1 >/dev/null 2>&1 + EXIT_CODE=$? + if [ ${EXIT_CODE} != 0 ] + then + echo "$1 is not installed" + exit ${EXIT_CODE} + fi +} + +cd $WORK_DIR +check_for python +check_for npm + +# if the program exits before this has been captured then there must have been an error +EXIT_CODE=1 + +# javascript code generate +npm install +grunt default + +TEST_SUITES=('grunt unit') + +for TEST in "${TEST_SUITES[@]}" +do + echo "##### Running test: [$TEST]" + $TEST + EXIT_CODE=$? + echo "##### Test [$TEST] ended with status [$EXIT_CODE]" + if [ ${EXIT_CODE} != 0 ] + then + cd - + exit ${EXIT_CODE} + fi +done + +cd - +exit 0 diff --git a/bin/stop-all-docker-containers.sh b/bin/stop-all-docker-containers.sh new file mode 100755 index 0000000000..39304daeb7 --- /dev/null +++ b/bin/stop-all-docker-containers.sh @@ -0,0 +1,6 @@ +#!/bin/bash +echo "Shutting down docker" + +docker kill $(docker ps -a -q) || echo "No docker containers to kill" +docker rm -v $(docker ps -a -q) || echo "No docker volumes to remove" +docker network prune -f diff --git a/bin/verify.sh b/bin/verify.sh new file mode 100755 index 0000000000..57dfca8757 --- /dev/null +++ b/bin/verify.sh @@ -0,0 +1,21 @@ +#!/bin/bash +echo 'Running tests...' + +echo 'Stopping any docker containers...' +./bin/stop-all-docker-containers.sh + +echo 'Starting up docker for integration testing and running unit and integration tests on all modules...' +sbt ";validate;verify" +verify_result=$? + +echo 'Stopping any docker containers...' +./bin/stop-all-docker-containers.sh + +if [ ${verify_result} -eq 0 ] +then + echo 'Verify successful!' + exit 0 +else + echo 'Verify failed!' + exit 1 +fi diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000000..f3670f864b --- /dev/null +++ b/build.sbt @@ -0,0 +1,299 @@ +import sbtprotobuf.{ProtobufPlugin => PB} +import Resolvers._ +import Dependencies._ +import CompilerOptions._ +import com.typesafe.sbt.packager.docker._ +import scoverage.ScoverageKeys.{coverageFailOnMinimum, coverageMinimum} +import org.scalafmt.sbt.ScalafmtPlugin._ +import microsites._ + +resolvers ++= additionalResolvers + +lazy val IntegrationTest = config("it") extend(Test) + +// Needed because we want scalastyle for integration tests which is not first class +val codeStyleIntegrationTest = taskKey[Unit]("enforce code style then integration test") +def scalaStyleIntegrationTest: Seq[Def.Setting[_]] = { + inConfig(IntegrationTest)(ScalastylePlugin.rawScalastyleSettings()) ++ + Seq( + scalastyleConfig in IntegrationTest := root.base / "scalastyle-test-config.xml", + scalastyleTarget in IntegrationTest := target.value / "scalastyle-it-results.xml", + scalastyleFailOnError in IntegrationTest := (scalastyleFailOnError in scalastyle).value, + (scalastyleFailOnWarning in IntegrationTest) := (scalastyleFailOnWarning in scalastyle).value, + scalastyleSources in IntegrationTest := (unmanagedSourceDirectories in IntegrationTest).value, + codeStyleIntegrationTest := scalastyle.in(IntegrationTest).toTask("").value + ) +} + +// Create a default Scala style task to run with tests +lazy val testScalastyle = taskKey[Unit]("testScalastyle") +def scalaStyleTest: Seq[Def.Setting[_]] = Seq( + (scalastyleConfig in Test) := baseDirectory.value / ".." / ".." / "scalastyle-test-config.xml", + scalastyleTarget in Test := target.value / "scalastyle-test-results.xml", + scalastyleFailOnError in Test := (scalastyleFailOnError in scalastyle).value, + (scalastyleFailOnWarning in Test) := (scalastyleFailOnWarning in scalastyle).value, + scalastyleSources in Test := (unmanagedSourceDirectories in Test).value, + testScalastyle := scalastyle.in(Test).toTask("").value +) + +lazy val compileScalastyle = taskKey[Unit]("compileScalastyle") +def scalaStyleCompile: Seq[Def.Setting[_]] = Seq( + compileScalastyle := scalastyle.in(Compile).toTask("").value +) + +def scalaStyleSettings: Seq[Def.Setting[_]] = scalaStyleCompile ++ scalaStyleTest ++ scalaStyleIntegrationTest + +// settings that should be inherited by all projects +lazy val sharedSettings = Seq( + organization := "vinyldns", + version := "0.8.0-SNAPSHOT", + scalaVersion := "2.12.6", + organizationName := "Comcast Cable Communications Management, LLC", + startYear := Some(2018), + licenses += ("Apache-2.0", new URL("https://www.apache.org/licenses/LICENSE-2.0.txt")), + scalacOptions += "-target:jvm-1.8", + scalacOptions ++= scalacOptionsByV(scalaVersion.value), + // Use wart remover to eliminate code badness + wartremoverErrors ++= Seq( + Wart.ArrayEquals, + Wart.EitherProjectionPartial, + Wart.IsInstanceOf, + Wart.JavaConversions, + Wart.Return, + Wart.LeakingSealed, + Wart.ExplicitImplicitTypes + ), + + // scala format + scalafmtOnCompile := true, + scalafmtOnCompile in IntegrationTest := true +) + +lazy val testSettings = Seq( + parallelExecution in Test := false, + parallelExecution in IntegrationTest := false, + fork in IntegrationTest := false, + testOptions in Test += Tests.Argument("-oD"), + logBuffered in Test := false +) + +lazy val apiSettings = Seq( + name := "api", + libraryDependencies ++= compileDependencies ++ testDependencies, + mainClass := Some("vinyldns.api.Boot"), + javaOptions in reStart += "-Dlogback.configurationFile=test/logback.xml", + coverageMinimum := 85, + coverageFailOnMinimum := true, + coverageHighlighting := true, + coverageExcludedPackages := ".*Boot.*" +) + +lazy val apiAssemblySettings = Seq( + assemblyJarName in assembly := "vinyldns.jar", + test in assembly := {}, + mainClass in assembly := Some("vinyldns.api.Boot"), + mainClass in reStart := Some("vinyldns.api.Boot"), + // there are some odd things from dnsjava including update.java and dig.java that we don't use + assemblyMergeStrategy in assembly := { + case "update.class"| "dig.class" => MergeStrategy.discard + case PathList("scala", "tools", "nsc", "doc", "html", "resource", "lib", "index.js") => MergeStrategy.discard + case PathList("scala", "tools", "nsc", "doc", "html", "resource", "lib", "template.js") => MergeStrategy.discard + case x => + val oldStrategy = (assemblyMergeStrategy in assembly).value + oldStrategy(x) + } +) + +lazy val apiDockerSettings = Seq( + dockerBaseImage := "openjdk:8u171-jdk", + dockerUsername := Some("vinyldns"), + packageName in Docker := "api", + dockerExposedPorts := Seq(9000), + dockerEntrypoint := Seq("/opt/docker/bin/boot"), + dockerExposedVolumes := Seq("/opt/docker/lib_extra"), // mount extra libs to the classpath + dockerExposedVolumes := Seq("/opt/docker/conf"), // mount extra config to the classpath + + // add extra libs to class path via mount + scriptClasspath in bashScriptDefines ~= (cp => cp :+ "/opt/docker/lib_extra/*"), + + // adds config file to mount + bashScriptExtraDefines += """addJava "-Dconfig.file=${app_home}/../conf/application.conf"""", + bashScriptExtraDefines += """addJava "-Dlogback.configurationFile=${app_home}/../conf/logback.xml"""", // adds logback + bashScriptExtraDefines += "(cd ${app_home} && ./wait-for-dependencies.sh && cd -)", + credentials in Docker := Seq(Credentials(Path.userHome / ".iv2" / ".dockerCredentials")), + dockerCommands ++= Seq( + Cmd("USER", "root"), // switch to root so we can install netcat + ExecCmd("RUN", "apt-get", "update"), + ExecCmd("RUN", "apt-get", "install", "-y", "netcat-openbsd"), + Cmd("USER", "daemon") // switch back to the daemon user + ), + composeFile := baseDirectory.value.getAbsolutePath + "/../../docker/docker-compose.yml" +) + +lazy val noPublishSettings = Seq( + publish := {}, + publishLocal := {}, + publishArtifact := false +) + +lazy val apiPublishSettings = Seq( + publishArtifact := false, + publishLocal := (publishLocal in Docker).value, + publish := (publish in Docker).value +) + +lazy val pbSettings = Seq( + version in ProtobufConfig := "2.6.1" +) + +lazy val allApiSettings = Revolver.settings ++ Defaults.itSettings ++ + apiSettings ++ + sharedSettings ++ + apiAssemblySettings ++ + testSettings ++ + apiPublishSettings ++ + apiDockerSettings ++ + pbSettings ++ + scalaStyleSettings + +lazy val api = (project in file("modules/api")) + .enablePlugins(JavaAppPackaging, DockerComposePlugin, AutomateHeaderPlugin, ProtobufPlugin) + .configs(IntegrationTest) + .settings(allApiSettings) + .settings(headerSettings(IntegrationTest)) + .settings(inConfig(IntegrationTest)(scalafmtConfigSettings)) + .dependsOn(core) + +lazy val root = (project in file(".")).enablePlugins(AutomateHeaderPlugin, ProtobufPlugin) + .configs(IntegrationTest) + .settings(headerSettings(IntegrationTest)) + .settings(sharedSettings) + .settings( + inConfig(IntegrationTest)(scalafmtConfigSettings), + (scalastyleConfig in Test) := baseDirectory.value / "scalastyle-test-config.xml", + (scalastyleConfig in IntegrationTest) := baseDirectory.value / "scalastyle-test-config.xml" + ) + .aggregate(core, api, portal) + +lazy val coreBuildSettings = Seq( + name := "core", + + // do not use unused params as NoOpCrypto ignores its constructor, we should provide a way + // to write a crypto plugin so that we fall back to a noarg constructor + scalacOptions ++= scalacOptionsByV(scalaVersion.value).filterNot(_ == "-Ywarn-unused:params") +) + +lazy val corePublishSettings = Seq( + publishMavenStyle := true, + publishArtifact in Test := false, + pomIncludeRepository := { _ => false }, + autoAPIMappings := true, + credentials += Credentials(Path.userHome / ".ivy2" / ".credentials"), + publish in Docker := {}, + mainClass := None +) + +lazy val core = (project in file("modules/core")).enablePlugins(AutomateHeaderPlugin) + .settings(sharedSettings) + .settings(coreBuildSettings) + .settings(corePublishSettings) + .settings(testSettings) + .settings(libraryDependencies ++= coreDependencies) + .settings(scalaStyleCompile ++ scalaStyleTest) + .settings( + coverageMinimum := 85, + coverageFailOnMinimum := true, + coverageHighlighting := true + ) + +val preparePortal = TaskKey[Unit]("preparePortal", "Runs NPM to prepare portal for start") +val checkJsHeaders = TaskKey[Unit]("checkJsHeaders", "Runs script to check for APL 2.0 license headers") +val createJsHeaders = TaskKey[Unit]("createJsHeaders", "Runs script to prepend APL 2.0 license headers to files") + +lazy val portal = (project in file("modules/portal")).enablePlugins(PlayScala, AutomateHeaderPlugin) + .settings(sharedSettings) + .settings(testSettings) + .settings(noPublishSettings) + .settings( + name := "portal", + libraryDependencies ++= portalDependencies, + routesGenerator := InjectedRoutesGenerator, + coverageMinimum := 75, + coverageExcludedPackages := ";views.html.*;router.*", + javaOptions in Test += "-Dconfig.file=conf/application-test.conf", + javaOptions in run += "-Dhttp.port=9001 -Dconfig.file=modules/portal/conf/application.conf", + + // adds an extra classpath to the portal loading so we can externalize jars, make sure to create the lib_extra + // directory and lay down any dependencies that are required when deploying + scriptClasspath in bashScriptDefines ~= (cp => cp :+ "lib_extra/*"), + mainClass in reStart := None, + + // we need to filter out unused for the portal as the play framework needs a lot of unused things + scalacOptions ~= { opts => opts.filterNot(p => p.contains("unused")) }, + + // runs our prepare portal process + preparePortal := { + import scala.sys.process._ + "./modules/portal/prepare-portal.sh" ! + }, + + checkJsHeaders := { + import scala.sys.process._ + "./bin/add-license-headers.sh -d=modules/portal/public/lib -f=js -c" ! + }, + + createJsHeaders := { + import scala.sys.process._ + "./bin/add-license-headers.sh -d=modules/portal/public/lib -f=js" ! + }, + + // change the name of the output to portal.zip + packageName in Universal := "portal" + ) + .dependsOn(core) + +lazy val docSettings = Seq( + git.remoteRepo := "https://github.com/vinyldns/vinyldns", + micrositeGithubOwner := "VinylDNS", + micrositeGithubRepo := "vinyldns", + micrositeName := "VinylDNS", + micrositeDescription := "DNS Management Platform", + micrositeAuthor := "VinylDNS", + micrositeHomepage := "http://vinyldns.io", + micrositeDocumentationUrl := "/apidocs", + micrositeGitterChannelUrl := "vinyldns/Lobby", + micrositeShareOnSocial := false, + micrositeExtraMdFiles := Map( + file("CONTRIBUTING.md") -> ExtraMdFileConfig( + "contributing.md", + "page", + Map("title" -> "Contributing", "section" -> "contributing", "position" -> "2") + ) + ), + ghpagesNoJekyll := false, + fork in tut := true +) + +lazy val docs = (project in file("modules/docs")).enablePlugins(MicrositesPlugin) + .settings(docSettings) + +// Validate runs static checks and compile to make sure we can go +addCommandAlias("validate-api", + ";project api; clean; headerCheck; test:headerCheck; it:headerCheck; scalastyle; test:scalastyle; " + + "it:scalastyle; compile; test:compile; it:compile") +addCommandAlias("validate-core", + ";project core; clean; headerCheck; test:headerCheck; scalastyle; test:scalastyle; compile; test:compile") +addCommandAlias("validate-portal", + ";project portal; clean; headerCheck; test:headerCheck; compile; test:compile; createJsHeaders; checkJsHeaders") +addCommandAlias("validate", ";validate-core;validate-api;validate-portal") + +// Verify runs all tests and code coverage +addCommandAlias("verify", + ";project api;dockerComposeUp;project root;coverage;test;it:test;coverageReport;coverageAggregate;project api;dockerComposeStop") + +// Build the artifacts for release +addCommandAlias("build-api", ";project api;clean;assembly") +addCommandAlias("build-portal", ";project portal;clean;preparePortal;dist") +addCommandAlias("build", ";build-api;build-portal") + + diff --git a/docker/api/.dockerignore b/docker/api/.dockerignore new file mode 100644 index 0000000000..f4ed141ba4 --- /dev/null +++ b/docker/api/.dockerignore @@ -0,0 +1,5 @@ +.DS_Store +.dockerignore +.git +.gitignore +classes \ No newline at end of file diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile new file mode 100644 index 0000000000..10690951c3 --- /dev/null +++ b/docker/api/Dockerfile @@ -0,0 +1,18 @@ +FROM openjdk:8u171-jdk-stretch + +RUN apt-get update && apt-get install -y netcat-openbsd + +# install the jar onto the server, asserts this Dockerfile is copied to target/scala-2.12 after a build +COPY vinyldns.jar /app/vinyldns-server.jar +COPY run.sh /app/run.sh +RUN chmod a+x /app/run.sh + +COPY docker.conf /app/docker.conf + +EXPOSE 9000 +EXPOSE 2551 + +# set the entry point for the container to start vinyl, specify the config resource +ENTRYPOINT ["/app/run.sh"] + + diff --git a/docker/api/docker.conf b/docker/api/docker.conf new file mode 100644 index 0000000000..7356b006cd --- /dev/null +++ b/docker/api/docker.conf @@ -0,0 +1,194 @@ +################################################################################################################ +# This configuration is only used by docker. Environment variables are required in order to start +# up a docker cluster appropriately, so most of the values are passed in here. Defaults assume a local docker compose +# for vinyldns running. +# SQS_ENDPOINT is the SQS endpoint +# SQS_QUEUE_URL is the full URL to the SQS queue +# SQS_REGION is the service region where the SQS queue lives (e.g. us-east-1) +# AWS_ACCESS_KEY is the AWS access key +# AWS_SECRET_ACCESS_KEY is the AWS secret access key +# JDBC_MIGRATION_URL - the URL for migations in the SQL database +# JDBC_URL - the full URL to the SQL database +# JDBC_USER - the SQL database user +# JDBC_PASSWORD - the SQL database password +# DYNAMODB_ENDPOINT - the endpoint for DynamoDB +# DEFAULT_DNS_ADDRESS - the server (and port if not 53) of the default DNS server +# DEFAULT_DNS_KEY_NAME - the default key name used to connect to the default DNS server +# DEFAULT_DNS_KEY_SECRET - the default key secret used to connect to the default DNS server +################################################################################################################ +vinyldns { + sqs { + embedded = false + access-key = "x" + access-key = ${?AWS_ACCESS_KEY} + + secret-key = "x" + secret-key = ${?AWS_SECRET_ACCESS_KEY} + signing-region = "x" + signing-region = ${?SQS_REGION} + service-endpoint = "http://vinyldns-elasticmq:9324/" + service-endpoint = ${?SQS_ENDPOINT} + queue-url = "http://vinyldns-elasticmq:9324/queue/vinyldns" + queue-url = ${?SQS_QUEUE_URL} + } + + rest { + host = "0.0.0.0" + port = 9000 + } + + sync-delay = 10000 + + monitoring { + logging-interval = 120s + } + + crypto { + type = "vinyldns.core.crypto.NoOpCrypto" + } + + # default settings point to the setup from docker compose + db { + name = "vinyldns" + local-mode = false # for docker only so we initialize the db every time + default { + driver = "org.mariadb.jdbc.Driver" + migrationUrl = "jdbc:mariadb://vinyldns-mysql:3306/?user=root&password=pass" + migrationUrl = ${?JDBC_MIGRATION_URL} + url = "jdbc:mariadb://vinyldns-mysql:3306/vinyldns?user=root&password=pass" + url = ${?JDBC_URL} + user = "root" + user = ${?JDBC_USER} + password = "pass" + password = ${?JDBC_PASSWORD} + poolInitialSize = 10 + poolMaxSize = 20 + connectionTimeoutMillis = 1000 + maxLifeTime = 600000 + } + } + + # default settings point to the docker compose setup + dynamo { + key = "x" + key = ${?AWS_ACCESS_KEY} + secret = "x" + secret = ${?AWS_SECRET_ACCESS_KEY} + endpoint = "http://vinyldns-dynamodb:8000" + endpoint = ${?DYNAMODB_ENDPOINT} + } + + zoneChanges { + dynamo { + tableName = "zoneChange" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + recordSet { + dynamo { + tableName = "recordSet" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + recordChange { + dynamo { + tableName = "recordChange" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + users { + dynamo { + tableName = "users" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + groups { + dynamo { + tableName = "groups" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + groupChanges { + dynamo { + tableName = "groupChanges" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + membership { + dynamo { + tableName = "membership" + provisionedReads = 30 + provisionedWrites = 30 + } + } + + defaultZoneConnection { + name = "vinyldns." + keyName = "vinyldns." + keyName = ${?DEFAULT_DNS_KEY_NAME} + + key = "nzisn+4G2ldMn0q1CV3vsg==" + key = ${?DEFAULT_DNS_KEY_SECRET} + + primaryServer = "vinyldns-bind9" + primaryServer = ${?DEFAULT_DNS_ADDRESS} + } + + defaultTransferConnection { + name = "vinyldns." + keyName = "vinyldns." + keyName = ${?DEFAULT_DNS_KEY_NAME} + + key = "nzisn+4G2ldMn0q1CV3vsg==" + key = ${?DEFAULT_DNS_KEY_SECRET} + + primaryServer = "vinyldns-bind9" + primaryServer = ${?DEFAULT_DNS_ADDRESS} + } + + batch-change-limit = 20 + + # log prometheus metrics to logger factory + metrics { + log-to-console = false + } +} + +akka { + loglevel = "INFO" + loggers = ["akka.event.slf4j.Slf4jLogger"] + logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" + logger-startup-timeout = 30s + + actor { + provider = "akka.actor.LocalActorRefProvider" + } +} + +akka.http { + server { + # The time period within which the TCP binding process must be completed. + # Set to `infinite` to disable. + bind-timeout = 5s + + # Show verbose error messages back to the client + verbose-error-messages = on + } + + parsing { + # Spray doesn't like the AWS4 headers + illegal-header-warnings = on + } +} diff --git a/docker/api/run.sh b/docker/api/run.sh new file mode 100644 index 0000000000..39e827f535 --- /dev/null +++ b/docker/api/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +# gets the docker-ized ip address, sets it to an environment variable +export APP_HOST=`ip addr show eth0 | grep 'inet ' | awk '{print $2}' | cut -f1 -d'/'` + +export DYNAMO_ADDRESS="vinyldns-dynamodb" +export DYNAMO_PORT=8000 +export JOURNAL_HOST="vinyldns-dynamodb" +export JOURNAL_PORT=8000 +export MYSQL_ADDRESS="vinyldns-mysql" +export MYSQL_PORT=3306 +export JDBC_USER=root +export JDBC_PASSWORD=pass +export DNS_ADDRESS="vinyldns-bind9" +export DYNAMO_KEY="local" +export DYNAMO_SECRET="local" +export DYNAMO_TABLE_PREFIX="" +export ELASTICMQ_ADDRESS="vinyldns-elasticmq" +export DYNAMO_ENDPOINT="http://${DYNAMO_ADDRESS}:${DYNAMO_PORT}" +export JDBC_URL="jdbc:mariadb://${MYSQL_ADDRESS}:${MYSQL_PORT}/vinyldns?user=${JDBC_USER}&password=${JDBC_PASSWORD}" +export JDBC_MIGRATION_URL="jdbc:mariadb://${MYSQL_ADDRESS}:${MYSQL_PORT}/?user=${JDBC_USER}&password=${JDBC_PASSWORD}" + +# wait until mysql is ready... +echo 'Waiting for MYSQL to be ready...' +DATA="" +RETRY=30 +while [ $RETRY -gt 0 ] +do + DATA=$(nc -vzw1 vinyldns-mysql 3306) + if [ $? -eq 0 ] + then + break + else + echo "Retrying Again" >&2 + + let RETRY-=1 + sleep .5 + + if [ $RETRY -eq 0 ] + then + echo "Exceeded retries waiting for MYSQL to be ready, failing" + return 1 + fi + fi +done + +echo "Running migrations..." +java -Dconfig.resource=db-migrations.conf -cp /app/vinyldns-server.jar db.migration.MigrationRunner + +echo "Starting up Vinyl..." +sleep 2 +java -Djava.net.preferIPv4Stack=true -Dconfig.file=/app/docker.conf -Dakka.loglevel=INFO -Dlogback.configurationFile=test/logback.xml -jar /app/vinyldns-server.jar vinyldns.api.Boot + diff --git a/docker/bind9/etc/named.conf.local b/docker/bind9/etc/named.conf.local new file mode 100755 index 0000000000..da50632663 --- /dev/null +++ b/docker/bind9/etc/named.conf.local @@ -0,0 +1,160 @@ +// +// Do any local configuration here +// + +// Consider adding the 1918 zones here, if they are not used in your +// organization +//include "/etc/bind/zones.rfc1918"; + +key "vinyldns." { + algorithm hmac-md5; + secret "nzisn+4G2ldMn0q1CV3vsg=="; +}; + +// Consider adding the 1918 zones here, if they are not used in your +// organization +//include "/etc/bind/zones.rfc1918"; +zone "vinyldns" { + type master; + file "/var/bind/vinyldns.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "old-vinyldns2" { + type master; + file "/var/bind/old-vinyldns2.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "old-vinyldns3" { + type master; + file "/var/bind/old-vinyldns3.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "old-shared" { + type master; + file "/var/bind/old-shared.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "dummy" { + type master; + file "/var/bind/dummy.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "ok" { + type master; + file "/var/bind/ok.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "shared" { + type master; + file "/var/bind/shared.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "system-test" { + type master; + file "/var/bind/system-test.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "system-test-history" { + type master; + file "/var/bind/system-test-history.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "30.172.in-addr.arpa" { + type master; + file "/var/bind/30.172.in-addr.arpa"; + allow-update { key "vinyldns."; }; + }; + +zone "2.0.192.in-addr.arpa" { + type master; + file "/var/bind/2.0.192.in-addr.arpa"; + allow-update { key "vinyldns."; }; + }; + +zone "192/30.2.0.192.in-addr.arpa" { + type master; + file "/var/bind/192^30.2.0.192.in-addr.arpa"; + allow-update { key "vinyldns."; }; + }; + +zone "1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa" { + type master; + file "/var/bind/1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa"; + allow-update { key "vinyldns."; }; + }; + +zone "one-time" { + type master; + file "/var/bind/one-time.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "sync-test" { + type master; + file "/var/bind/sync-test.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "invalid-zone" { + type master; + file "/var/bind/invalid-zone.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "list-zones-test-searched-1" { + type master; + file "/var/bind/list-zones-test-searched-1.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "list-zones-test-searched-2" { + type master; + file "/var/bind/list-zones-test-searched-2.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "list-zones-test-searched-3" { + type master; + file "/var/bind/list-zones-test-searched-3.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "list-zones-test-unfiltered-1" { + type master; + file "/var/bind/list-zones-test-unfiltered-1.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "list-zones-test-unfiltered-2" { + type master; + file "/var/bind/list-zones-test-unfiltered-2.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "one-time-shared" { + type master; + file "/var/bind/one-time-shared.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "parent.com" { + type master; + file "/var/bind/parent.com.hosts"; + allow-update { key "vinyldns."; }; + }; + +zone "child.parent.com" { + type master; + file "/var/bind/child.parent.com.hosts"; + allow-update { key "vinyldns."; }; + }; + diff --git a/docker/bind9/zones/1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa b/docker/bind9/zones/1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa new file mode 100755 index 0000000000..f7842ea63d --- /dev/null +++ b/docker/bind9/zones/1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa @@ -0,0 +1,10 @@ +$ttl 38400 +1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa. IN SOA 172.17.42.1. admin.vinyldns.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa. IN NS 172.17.42.1. +4.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0 IN PTR www.vinyldns. +5.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0 IN PTR mail.vinyldns. diff --git a/docker/bind9/zones/192^30.2.0.192.in-addr.arpa b/docker/bind9/zones/192^30.2.0.192.in-addr.arpa new file mode 100644 index 0000000000..89b539aca1 --- /dev/null +++ b/docker/bind9/zones/192^30.2.0.192.in-addr.arpa @@ -0,0 +1,11 @@ +$ttl 38400 +192/30.2.0.192.in-addr.arpa. IN SOA 172.17.42.1. admin.vinyldns.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +192/30.2.0.192.in-addr.arpa. IN NS 172.17.42.1. +192 IN PTR portal.vinyldns. +194 IN PTR mail.vinyldns. +195 IN PTR test.vinyldns. diff --git a/docker/bind9/zones/2.0.192.in-addr.arpa b/docker/bind9/zones/2.0.192.in-addr.arpa new file mode 100644 index 0000000000..9f4d04e355 --- /dev/null +++ b/docker/bind9/zones/2.0.192.in-addr.arpa @@ -0,0 +1,13 @@ +$ttl 38400 +2.0.192.in-addr.arpa. IN SOA 172.17.42.1. admin.vinyldns.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +2.0.192.in-addr.arpa. IN NS 172.17.42.1. +192/30 IN NS 172.17.42.1. +192 IN CNAME 192.192/30.2.0.192.in-addr.arpa. +193 IN CNAME 193.192/30.2.0.192.in-addr.arpa. +194 IN CNAME 194.192/30.2.0.192.in-addr.arpa. +195 IN CNAME 195.192/30.2.0.192.in-addr.arpa. diff --git a/docker/bind9/zones/30.172.in-addr.arpa b/docker/bind9/zones/30.172.in-addr.arpa new file mode 100755 index 0000000000..dda5a3dd42 --- /dev/null +++ b/docker/bind9/zones/30.172.in-addr.arpa @@ -0,0 +1,10 @@ +$ttl 38400 +30.172.in-addr.arpa. IN SOA 172.17.42.1. admin.vinyldns.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +30.172.in-addr.arpa. IN NS 172.17.42.1. +24.0 IN PTR www.vinyl. +25.0 IN PTR mail.vinyl. diff --git a/docker/bind9/zones/child.parent.com.hosts b/docker/bind9/zones/child.parent.com.hosts new file mode 100644 index 0000000000..a746305422 --- /dev/null +++ b/docker/bind9/zones/child.parent.com.hosts @@ -0,0 +1,9 @@ +$ttl 38400 +$ORIGIN child.parent.com. +@ IN SOA ns1.parent.com. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +@ IN NS ns1.parent.com. diff --git a/docker/bind9/zones/dummy.hosts b/docker/bind9/zones/dummy.hosts new file mode 100644 index 0000000000..d742b4da0d --- /dev/null +++ b/docker/bind9/zones/dummy.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +dummy. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +dummy. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/invalid-zone.hosts b/docker/bind9/zones/invalid-zone.hosts new file mode 100644 index 0000000000..47eae69438 --- /dev/null +++ b/docker/bind9/zones/invalid-zone.hosts @@ -0,0 +1,17 @@ +$ttl 38400 +invalid-zone. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +invalid-zone. IN NS 172.17.42.1. +invalid-zone. IN NS not-approved.thing.com. +invalid.child.invalid-zone. IN NS 172.17.42.1. +dotted.host.invalid-zone. IN A 1.2.3.4 +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/list-zones-test-searched-1.hosts b/docker/bind9/zones/list-zones-test-searched-1.hosts new file mode 100644 index 0000000000..c2cf966f76 --- /dev/null +++ b/docker/bind9/zones/list-zones-test-searched-1.hosts @@ -0,0 +1,8 @@ +$ttl 38400 +list-zones-test-searched-1. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +list-zones-test-searched-1. IN NS 172.17.42.1. diff --git a/docker/bind9/zones/list-zones-test-searched-2.hosts b/docker/bind9/zones/list-zones-test-searched-2.hosts new file mode 100644 index 0000000000..b531d2a191 --- /dev/null +++ b/docker/bind9/zones/list-zones-test-searched-2.hosts @@ -0,0 +1,8 @@ +$ttl 38400 +list-zones-test-searched-2. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +list-zones-test-searched-2. IN NS 172.17.42.1. diff --git a/docker/bind9/zones/list-zones-test-searched-3.hosts b/docker/bind9/zones/list-zones-test-searched-3.hosts new file mode 100644 index 0000000000..33e76e90f6 --- /dev/null +++ b/docker/bind9/zones/list-zones-test-searched-3.hosts @@ -0,0 +1,8 @@ +$ttl 38400 +list-zones-test-searched-3. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +list-zones-test-searched-3. IN NS 172.17.42.1. diff --git a/docker/bind9/zones/list-zones-test-unfiltered-1.hosts b/docker/bind9/zones/list-zones-test-unfiltered-1.hosts new file mode 100755 index 0000000000..9205eec0df --- /dev/null +++ b/docker/bind9/zones/list-zones-test-unfiltered-1.hosts @@ -0,0 +1,8 @@ +$ttl 38400 +list-zones-test-unfiltered-1. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +list-zones-test-unfiltered-1. IN NS 172.17.42.1. diff --git a/docker/bind9/zones/list-zones-test-unfiltered-2.hosts b/docker/bind9/zones/list-zones-test-unfiltered-2.hosts new file mode 100755 index 0000000000..dfdb66493e --- /dev/null +++ b/docker/bind9/zones/list-zones-test-unfiltered-2.hosts @@ -0,0 +1,8 @@ +$ttl 38400 +list-zones-test-unfiltered-2. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +list-zones-test-unfiltered-2. IN NS 172.17.42.1. diff --git a/docker/bind9/zones/ok.hosts b/docker/bind9/zones/ok.hosts new file mode 100755 index 0000000000..8c0a604d39 --- /dev/null +++ b/docker/bind9/zones/ok.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +ok. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +ok. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/old-shared.hosts b/docker/bind9/zones/old-shared.hosts new file mode 100755 index 0000000000..a7c06b6d13 --- /dev/null +++ b/docker/bind9/zones/old-shared.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +old-shared. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +old-shared. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/old-vinyldns2.hosts b/docker/bind9/zones/old-vinyldns2.hosts new file mode 100755 index 0000000000..5fdc55ce9e --- /dev/null +++ b/docker/bind9/zones/old-vinyldns2.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +old-vinyldns2. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +old-vinyldns2. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/old-vinyldns3.hosts b/docker/bind9/zones/old-vinyldns3.hosts new file mode 100755 index 0000000000..5d514886a5 --- /dev/null +++ b/docker/bind9/zones/old-vinyldns3.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +old-vinyldns3. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +old-vinyldns3. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/one-time-shared.hosts b/docker/bind9/zones/one-time-shared.hosts new file mode 100755 index 0000000000..654f015573 --- /dev/null +++ b/docker/bind9/zones/one-time-shared.hosts @@ -0,0 +1,8 @@ +$ttl 38400 +one-time-shared. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +one-time-shared. IN NS 172.17.42.1. diff --git a/docker/bind9/zones/one-time.hosts b/docker/bind9/zones/one-time.hosts new file mode 100755 index 0000000000..df072413ed --- /dev/null +++ b/docker/bind9/zones/one-time.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +one-time. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +one-time. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/parent.com.hosts b/docker/bind9/zones/parent.com.hosts new file mode 100755 index 0000000000..c3dc749f66 --- /dev/null +++ b/docker/bind9/zones/parent.com.hosts @@ -0,0 +1,15 @@ +$ttl 38400 +$ORIGIN parent.com. +@ IN SOA ns1.parent.com. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +parent.com. IN NS ns1.parent.com. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +already-exists IN A 6.6.6.6 +ns1 IN A 172.17.42.1 diff --git a/docker/bind9/zones/shared.hosts b/docker/bind9/zones/shared.hosts new file mode 100755 index 0000000000..81d0f9fea3 --- /dev/null +++ b/docker/bind9/zones/shared.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +shared. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +shared. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/sync-test.hosts b/docker/bind9/zones/sync-test.hosts new file mode 100755 index 0000000000..72024b6331 --- /dev/null +++ b/docker/bind9/zones/sync-test.hosts @@ -0,0 +1,17 @@ +$ttl 38400 +sync-test. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +sync-test. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 +fqdn.sync-test. IN A 7.7.7.7 +_sip._tcp IN SRV 10 60 5060 foo.sync-test. +existing.dotted IN A 9.9.9.9 diff --git a/docker/bind9/zones/system-test-history.hosts b/docker/bind9/zones/system-test-history.hosts new file mode 100755 index 0000000000..1408efda64 --- /dev/null +++ b/docker/bind9/zones/system-test-history.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +system-test-history. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +system-test-history. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/system-test.hosts b/docker/bind9/zones/system-test.hosts new file mode 100755 index 0000000000..02f493ffca --- /dev/null +++ b/docker/bind9/zones/system-test.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +system-test. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +system-test. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/bind9/zones/vinyldns.hosts b/docker/bind9/zones/vinyldns.hosts new file mode 100644 index 0000000000..905211823e --- /dev/null +++ b/docker/bind9/zones/vinyldns.hosts @@ -0,0 +1,14 @@ +$ttl 38400 +vinyldns. IN SOA 172.17.42.1. admin.test.com. ( + 1439234395 + 10800 + 3600 + 604800 + 38400 ) +vinyldns. IN NS 172.17.42.1. +jenkins IN A 10.1.1.1 +foo IN A 2.2.2.2 +test IN A 3.3.3.3 +test IN A 4.4.4.4 +@ IN A 5.5.5.5 +already-exists IN A 6.6.6.6 diff --git a/docker/docker-compose-build.yml b/docker/docker-compose-build.yml new file mode 100644 index 0000000000..975a21f7e0 --- /dev/null +++ b/docker/docker-compose-build.yml @@ -0,0 +1,48 @@ +version: "3.0" +services: + mysql: + image: "mysql:5.7" + container_name: "vinyldns-mysql" + environment: + - MYSQL_ROOT_PASSWORD=pass # do not use quotes around the environment variables!!! + - MYSQL_ROOT_HOST=% # this is required as mysql is currently locked down to localhost + ports: + - "3306:3306" + + dynamodb: + image: "cnadiminti/dynamodb-local:2017-02-16" + container_name: "vinyldns-dynamodb" + ports: + - "19000:8000" + command: "--sharedDb --inMemory" + + bind9: + image: "vinyldns/bind9:0.0.1" + container_name: "vinyldns-bind9" + ports: + - "19001:53/udp" + - "19001:53" + volumes: + - ./bind9/etc:/var/cache/bind/config + - ./bind9/zones:/var/cache/bind/zones + + elasticmq: + image: s12v/elasticmq:0.13.8 + container_name: "vinyldns-elasticmq" + ports: + - "9324:9324" + volumes: + - ./elasticmq/custom.conf:/etc/elasticmq/elasticmq.conf + + api: + image: vinyldns/api:0.1 # the version of the docker container we want to pull + environment: + - REST_PORT=9000 + container_name: "vinyldns-api" + ports: + - "9000:9000" + depends_on: + - mysql + - bind9 + - elasticmq + - dynamodb diff --git a/docker/docker-compose-func-test.yml b/docker/docker-compose-func-test.yml new file mode 100644 index 0000000000..110de98859 --- /dev/null +++ b/docker/docker-compose-func-test.yml @@ -0,0 +1,56 @@ +version: "3.0" +services: + mysql: + image: "mysql:5.7" + container_name: "vinyldns-mysql" + environment: + - MYSQL_ROOT_PASSWORD=pass # do not use quotes around the environment variables!!! + - MYSQL_ROOT_HOST=% # this is required as mysql is currently locked down to localhost + ports: + - "3306:3306" + + dynamodb: + image: "cnadiminti/dynamodb-local:2017-02-16" + container_name: "vinyldns-dynamodb" + ports: + - "19000:8000" + + bind9: + image: "vinyldns/bind9:0.0.1" + container_name: "vinyldns-bind9" + volumes: + - ./bind9/etc:/var/cache/bind/config + - ./bind9/zones:/var/cache/bind/zones + ports: + - "19001:53/tcp" + - "19001:53/udp" + + elasticmq: + image: s12v/elasticmq:0.13.8 + container_name: "vinyldns-elasticmq" + ports: + - "9324:9324" + volumes: + - ./elasticmq/custom.conf:/etc/elasticmq/elasticmq.conf + + # this file is copied into the target directory to get the jar! won't run in place as is! + api: + build: + context: api + environment: + - REST_PORT=9000 + container_name: "vinyldns-api" + ports: + - "9000:9000" + depends_on: + - mysql + - bind9 + - elasticmq + - dynamodb + + functest: + build: + context: functest + container_name: "vinyldns-functest" + depends_on: + - api diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000..15015e87e7 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3.0" +services: + mysql: + image: "mysql:5.7" + environment: + - MYSQL_ROOT_PASSWORD=pass # do not use quotes around the environment variables!!! + - MYSQL_ROOT_HOST=% # this is required as mysql is currently locked down to localhost + ports: + - "3306:3306" + + dynamodb: + image: "cnadiminti/dynamodb-local:2017-02-16" + ports: + - "19000:8000" + command: "--sharedDb --inMemory" + + bind9: + image: "vinyldns/bind9:0.0.1" + ports: + - "19001:53/udp" + - "19001:53" + volumes: + - ./bind9/etc:/var/cache/bind/config + - ./bind9/zones:/var/cache/bind/zones + + elasticmq: + image: s12v/elasticmq:0.13.8 + ports: + - "9324:9324" + volumes: + - ./elasticmq/custom.conf:/etc/elasticmq/elasticmq.conf diff --git a/docker/elasticmq/Dockerfile b/docker/elasticmq/Dockerfile new file mode 100644 index 0000000000..a9f515970a --- /dev/null +++ b/docker/elasticmq/Dockerfile @@ -0,0 +1,10 @@ +FROM alpine:3.2 +FROM anapsix/alpine-java:8_server-jre + +EXPOSE 9324 + +COPY run.sh /elasticmq/run.sh +COPY custom.conf /elasticmq/custom.conf +COPY elasticmq-server-0.13.2.jar /elasticmq/server.jar + +ENTRYPOINT ["/elasticmq/run.sh"] diff --git a/docker/elasticmq/custom.conf b/docker/elasticmq/custom.conf new file mode 100644 index 0000000000..d0546d48a2 --- /dev/null +++ b/docker/elasticmq/custom.conf @@ -0,0 +1,32 @@ +include classpath("application.conf") + +node-address { + protocol = http + host = "localhost" + host = ${?APP_HOST} + port = 9324 + context-path = "" +} + +rest-sqs { + enabled = true + bind-port = 9324 + bind-hostname = "0.0.0.0" + // Possible values: relaxed, strict + sqs-limits = relaxed +} + +queues { + vinyldns { + defaultVisibilityTimeout = 10 seconds + receiveMessageWait = 0 seconds + } + vinyldns-bind9 { + defaultVisibilityTimeout = 10 seconds + receiveMessageWait = 0 seconds + } + vinyldns-zones { + defaultVisibilityTimeout = 10 seconds + receiveMessageWait = 0 seconds + } +} diff --git a/docker/elasticmq/run.sh b/docker/elasticmq/run.sh new file mode 100755 index 0000000000..f498d5992f --- /dev/null +++ b/docker/elasticmq/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# gets the docker-ized ip address, sets it to an environment variable +export APP_HOST=`ip addr show eth0 | grep 'inet ' | awk '{print $2}' | cut -f1 -d'/'` + +echo "APP HOST = ${APP_HOST}" + +java -Djava.net.preferIPv4Stack=true -Dconfig.file=/elasticmq/custom.conf -jar /elasticmq/server.jar diff --git a/docker/functest/Dockerfile b/docker/functest/Dockerfile new file mode 100644 index 0000000000..0ee4715e8d --- /dev/null +++ b/docker/functest/Dockerfile @@ -0,0 +1,20 @@ +FROM python:2.7.15-stretch + +# Install dns utils so we can run dig +RUN apt-get update && apt-get install dnsutils -y + +# The run script is what actually runs our func tests +COPY run.sh /app/run.sh +RUN chmod a+x /app/run.sh + +COPY run-tests.py /app/run-tests.py +RUN chmod a+x /app/run-tests.py + +# Copy over the functional test directory, this must have been copied into the build context previous to this building! +ADD functional_test /app + +# Install our func test requirements +RUN pip install --index-url https://pypi.python.org/simple/ -r /app/requirements.txt + +# set the entry point for the container to start vinyl, specify the config resource +ENTRYPOINT ["/app/run.sh"] diff --git a/docker/functest/run-tests.py b/docker/functest/run-tests.py new file mode 100644 index 0000000000..1d270a6d5e --- /dev/null +++ b/docker/functest/run-tests.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +import os +import sys + +basedir = os.path.dirname(os.path.realpath(__file__)) + +report_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../target/pytest_reports') +if not os.path.exists(report_dir): + os.system('mkdir -p ' + report_dir) + +import pytest + +result = 1 +result = pytest.main(list(sys.argv[1:])) + +sys.exit(result) + + diff --git a/docker/functest/run.sh b/docker/functest/run.sh new file mode 100644 index 0000000000..56f7baa66f --- /dev/null +++ b/docker/functest/run.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +VINYLDNS_URL="http://vinyldns-api:9000" +echo "Waiting for API to be ready at ${VINYLDNS_URL} ..." +DATA="" +RETRY=40 +while [ $RETRY -gt 0 ] +do + DATA=$(wget -O - -q -t 1 "${VINYLDNS_URL}/ping") + if [ $? -eq 0 ] + then + break + else + echo "Retrying Again" >&2 + + let RETRY-=1 + sleep 1 + + if [ $RETRY -eq 0 ] + then + echo "Exceeded retries waiting for VINYLDNS to be ready, failing" + exit 1 + fi + fi +done + +DNS_IP=$(dig +short vinyldns-bind9) +echo "Running live tests against ${VINYLDNS_URL} and DNS server ${DNS_IP}" + +cd /app +./run-tests.py live_tests -v --url=${VINYLDNS_URL} --dns-ip=${DNS_IP} diff --git a/img/vinyldns-full-logo-light.png b/img/vinyldns-full-logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..cba2b103dc3edc66b8cca9275fd6733475d606fd GIT binary patch literal 83879 zcmeFZWmr^E_Xj!{fS`f`QX(SV4BZGw4BZ_9(k0y?DJ3$LG*ZIA&;!yS(lGQO-Q5jC z-Q)ZE-sgV4pZ}X@1~{|N-fOSD_R8Ox2o)unCpcs{AQ0#YME0E;2!v?@0^JM4#sscZ z+KU|nzc5YZW!{1A{{Cb){fGyyJo+fB>jDBjZU6hi=yELl09?d!g(ym4En$)2VPZot z2;@Pa*C5C{aShL@omr1`L#ejA!~Onrm;Ko!J8#<1w;T6xg0OKSUGWL=KT)dXJt~Y1 zdi`2!=i-8aAu{sK=QnSMyX2xeQC1sxS+@bKzdxy%^WBVX;DkUB48>TP_5 zX!e+kO=G{iNhAozqdd!;xq1O1m$w;h*-IdYu*;^#7i$E9i(y7WUid!b2mAvV^=e zlg#N9)iEPU4vt=@>4eS(lr3Ab3@Z&3UB4|RiHI{+LuLDX8G;aC*;0Kc1c4lvz2u&O zHl9hSL=OwD=OE~`ic?_YFg?B0*?4795v%hiyz7OpRJ;4hC*RHcl9V@gc5vAH(~Kik zrAN~I%sEM@_x%d}uMd>nd3bn4hR4VKu0kFyJ2qd2iOo+852wfTG?Q{?GJE;CfeoXq zOLnBXW9l+ARPfy1e+ReTD21(|; ziqPLA%Z?4plqP38>z#gwC1rkg-Q^K2K0Ak-$~Cokw-w;E8>B$Kbq)UupID|6nr@|f42yu_#pz$6@Wlz1c>`q<;Wx! zeqn$D`Cb^KUz%z>KD0mAM_p049wY-w0{C-dn{jiELjeIb?uN6yUL2{iY52y^YQpo%EY+4wRNVbnE(E9*$03Pf7?a|wn-oc zT}ZZbzG+%ossi-qmU+w7@@KOzLaQM(kf-xj%TIn_Qm#mLGbKjybe2T5+s5S4bdnU0 z_s5d)NOb-Q#uM227R;Ag-$q0qu=OWuzG=6?!SlRkUn9C=p{Y!;i?rdtGl9G*B}m?b zdR)qAoFFdh>i(;m0mFtK^Bya!Eq9|8W}VS+^SQv3%ZJnE6WRGvk0GnpLmNE)oJrlb

fff4Ks=k>He?Z}H&UtzRN+s+41 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

TTH}G!N;;{3={W?d-uT z`smG1+E%I7!~W2*s#xg-uNZCZTkUxjX!XaooN$SocgGl|??2R(-lay4ZjYlJdHDDg z8D8MDZ*3b-{Bf)LPa31i=gK~7#^5=B^X~-;|LkU zMqdSJ@o_&HPeQo(W4xd3YfP^?TAyV;zZBb^$frU(k27ntW|!lfbCvG!h%h_?^a=yHc94_D-1C@qjkDa8Vdl}>-OtVW^f7s zyJ@g#tF6D>WghC%C{AI0fxET0cSbpN6Bw(Ib-2Pq9fn740Jo_7Q+vT$(@-!3po6;< zboLA7*>2hwp$P>Mrg_#6Knnv$s&XaIA(LIDsEr%8u?`?R?lH=XaJM`aiVYig;!gT_pd!#dWjt5{Tl>5F|qrQxP+1 z5>zn&QhKkxOXf?)+3#qv7%~B8t2%?8Xz)wuIj`rwy^DH0+r=h(v)6UR9Z!W?k+hKk z2v~JbV>DMK^a#(W^nCW9u#|#ZDO?W-ULIDA(+3)53neqt9?jS3FSmSz-+p_F>S8iV ziN;Al>J!t_MpS=HnQLgL6t-V&d*|7xhqh2tM#Q$TxG{ znN`Uv3L!sqycjuvp{#Fc;4)K?d(}5Aa)&)q z+p>{i+3`*J(qf)k%7m~@OEh2oex_v~;P;H<7K;*us@yXuWsJK!npWc34*YwG z#kIU&5w`KlX}r{4*A?{A0CXfYaHE4rb`IsaI!Rx(`SiU+J zzAE~WS@-eczyzpTrW3T5e0mlF97?q()G}ZA&)O*k?0n=N0FU@&ns*0BSB+3<%LCE) z{LdTfjI0`K9RY2&QTCd%h=SWUX7<#a-R3U{t2fQwpt!#LjHbli znJkv-B6CeC-OqBrw~}i|R=pGhS_}PQ=XaL-jms;r-p)IbRi){qSv#g8nv)lVdFMyD zj4w3a5i%?Kbx0g*YV1%vB=W-$e?VyGN~D7S^$o^j{O2Qun>`lW{lL?G0xe<|ypLop z<9GUn=LaoyIUCY;(p~n{2akmZl`9&_$lv0v$`tx``c#sefsKxaK5$Nk-oM`wCdxR6 z^~N@BdP=Vr!-9HOGYl+uzjP=eE6I^bp$y4IHbSF-{XPb$&-y*(*fv*qC{BW^oMDE$ z9n4i-&4nBNv>H~YN7UA;vL!cFzb4lc-$`Fyyc0eW*}oBm1wD{>AddgQ#IGSQhZHe> zv0NHDFVb4pbAS;a{(HG-w^fODC}&9SyFoAdRlLk17irRiZ$B0o--8q1?#WN$;>dnt z4IIX(By*1u5&dm7cIQe`4*tXwpo#Fe4`?a{$+h{mBlnI#j!j{YYDIfu|g`F zMe~Vjsln$$1dZI{YIHDuxvLlCdAgIMs8vOHSnF) z`~1q;?$fbeuiufydulLXWX=_6)eGVFD~Qx4s=Z1_Axo~5viS}Z$?eO$ZtlG!yRH0M zQ)|?I5&>6;q|m=V4RZ8$Aj*BTqOx+R!&#`H@u)OEvwDlN_uzxL=R=9NmvWVV{O&~0 z#E!j7%i+;37{o#SMPxRVumtFeU<9%;Q{A1SD+5cxLUD_^$6Gh^iSmvLX>cUP92Wl~ z=A1`+0DqP+wdR9TXK@MFuBgvAt@?(fPI-hh*5jB%Z?hjKs3h=`_u#yLk%&v8ES*Z9 ze0n@~pVDHnCL@mxpLcc1p=6qjJtYJ@mGJ56Q*Alh@!0!F`U{50lSm?F`t$Si(Xp{S z32Zfdu9xszo8q6SB$U%!9WRrvnovkk2c~SI8eZwT6?PCudQ1^NDn%iT|I6fhAoZXz z!1nklJ$#`q4K$qrn8`y;Z)+ zXFHni{HM}ArRNmGpv5SPF|5>l27I(jPK#4G#{Y7EKzMKLZ;)_y`MQT`9WnAJ(XL#Vw zTRfmt9eDd%V>-TPb>Q?wbRkBXG!#Q&hR(ePZ3byX$Zm++7U`;s!rGD#pH*P5Zx{qPK`uN)de+UkTW zg#js-pq6GUeBeBT#r){oJdxlxetdOl`*iVg$z6YIDXSjnrcn=!+Tvh4L)ry_H*>Xn zA2igF(9MuOeqnWghEhx8xe)tFpQ(1nX|YOCSEH@%h=={aZ0oFkOvt!Yjn34dzxG_m zt&aPf!Lv}|PA}gbS;^I;mKo0%WJzj3rDf)P&wPn=;&9tes3GCGS^~{`-g^Z*vZQUF zfDIIBsS^w6>4Q0P^fJ=v3v91Niye_3rN{4P=Zn$B)v*t9nT$k>lZUd>sqC5anD0&c zU~=K5_e&N>&bYLFXVEv7B39?rT9$mRarZO}%sF-6Nbpf^x{FEAh&i5eI>)PZ-*!3^ zP$OPOk$mmFHO&}KYbl;aUI@nbx+0F8{3bJr)cW4HSyY6=dgPdxk;U321_Ihpr4MtjvozC(x2^B>}#hHM+;06QtOHKOy z=4m$KB)Qc>mF~WekiR0o_;I}Xw#&GyRC}gx{PHOs-tXT$seur*Ebx#%=)d0!aFkT| z?d^G$f%ie_^;YNk!Qj?Jj+z_?PI7IUMYbyhv%&O^BneSBO)+9Q<~ z`+oYE@_nq2r4zr`myv-F)K4m?#hJ`PF!R3U9KE>9@cHJ7dSXDTIYle?)|-0izF_1+ zqdx!o6_&G*3Y7P$WOFB>x}EwvG&}%&gv;k_R1?4K7g$N1cx>df`Dx7N4gB-ctL%RG z?sDB}Q10b9L2=!AUIr=2gaOZE;Yl0KB0jRL+TWES>_~acys2rKg8(rx$HnzQ;cyh{ z#csOoA4_9#TWphG^vOo&H5z{*<-!5fiZ3FC1|n$k%hTt)Wlv};EbZ2PQB~&U*60|c zZHz`cU^20vNUGVpInN+=oX2mjMTLSO9U%{-3f2-De%XGG?EBU<{nU4H=GR2shi64v5JFJ3N~Vu`-z#JuTfI%wT#qUXHIYB*?k#A+ANt@)C)Y>~Pq=1?>c z&lU@eD>19C*c#$Ef8TPxyG#MsC%^Q_w{Oc%!*$8Y=7CS+4c?CHE_mh#?LC1H{CLvZ z&pC`6(n!~5`jr)#eLBD3!>Y2)6Ay3_#@|8W5@`%>rk5|x@=|yz5{VkV=wJD%RyZI2 zvV6QMTfJIbC2!=~(IjI#@J!~V;6c$~`f0kF4HsF}4fLKGWzfL8lwAx7Z0G7Q(s7Z) zOJ4^r%3tqZhkbXkOHivfVB>OH3i&~A$D65E^wPPmgr)KIV|IZ+l%Bx)wdfUP^ThST zsM4LCmyik_xdgQ(VY_>E=>ryCE5nlwB1he3;V<2Of3M|F*0YW24|@hUd}Kk@sexYe zh@GS9faCDOM9AvYV;fAkim0?l^;)WOao9Fw>2M-Mm-hLX8Yz>d}bzISLRLQ>g_AQo9 zO37Lp@vA;JETy$gRzZ|j?UWjh^kOt!oV!=q@}hqQF_w^kn;U!+EPdC2c9yI$mKz1i zxzfi`cMYz+Wo%7BcD!jckT;lrINGiP5vYN*+Cbo&1%AUjW69;AkF8IA3Nbyfpfq)q ztt71Q?gu8{z9)^t7HC)TNvw}b#Ix2DC4-mxPL*)h!Yz9o3SL=ca?RA^1mOTE~*ju&uBiz~?-3W}g$ z(wIsdOB%p_m-@AepHfymR!-Q$Tj(W!iqwtqO0srKnh3)&MzJo*60C?(M!!%J_R0FD z$!Bqg{IbGf?Y_n@7Uvbd{BLT3tA9v|xlB~bu*=jVFp6Z%9~;~<+$M-Q9R-A%px$7`MP3` zWPEEJaMzkUo~MwDnVAR<%bc4#E9IFrgB>YXi&cvp ztDF1uX~|4wpcnAbeAC1EQGxUO*|lHve;)Lt0bi@2H_Ck~$4FgVWo`c$&I4rq1cenx zDKOUaQ#J4(h!>irW5GG}7Uu&f8g9@0Z{O>GTM;Q0JZUPJQ_52nwSd?X$gtcJCRbau zJ#@wX8BH}-uU?{=Z?2o?B%1B}miP_71#^A7_T|9DnR&6om#RRbwq;uE*JA|c;YgB; zUAr05fHhoI+$@mQLH0>`Hrb~EmOcFj`X$Mxr}q|^T_%4e#(o#Q&~MJez5cz@9VS<) z%6Fjap_S`P>icU;BD3sO714BTL+QL<s zkuU-cc@|z;eg-urAk``J|!`;Mz>=!J$QwjO{dXJc!*;%E?M$t}TqDt`=imiZXuGG9I;zxJgu(*9Bdo4EES0UpZTI{)for++$V>t%7ZkX2g*evRu zi3Xhk0)V;AwchGm@-z%SG)rdC#MqjB&>WoQ0Y!Lu16?buQ!k?iV#UC6g*SN?%GNCf zH9NKpaf+E#xf1WPzp91Zz6WYZ@9zw++^cZ4z2w--A>lYsMh5eioI+==1f`grX(Jo$ zSR+W77O$Ae5MvJoYJ#aAGbgy}h#oaFQJ}eok#z2EZQ)+#?=)UlO}lPXcSm{*Is6k-^*3v~;PF0na$z88^O)R4+)g-B-I~`GwxJWpa*BegL zl&Q@a8F1ts5639*Dussf$YSF0(T*fb}*UMBGjVkA7<`qBn5L={>R}AzbaFOl(#`94%FVH3emPJ9U z{us=Hu?OqL-B5*3a#A3?<(wyfDLw-V;Y0Os(9LgYG|n+k&>pl9^Swc9`JjFE(cOT8 zVILxtLO%pk5Cqpzz$B()*J4O33LX1IRZ&h+L-fz9BKJG3kG|HEwvu!=dldmPMGFl6 zL!Jx4V1F)uSm5P5h&reuP32f^VnjJ&tdE6J-OW!et7q^E?KT@kz+(gIYe-d{Cnu%8 zZTpuX6P^~ZZO4Q;Bansrqv}`tPF(_(jfKH;h#i`6{=bqm72deS&tZG0sHD+=+ft*> zxRh_N#x<+PaDRlUxv?>xB0Omlx%W{#qevPkTFji8k;K_dje zKU#ugh(EF;9SP-#!=x34lm*xB;k>B8BU9@(v2D#T;(qITt*aZ7eBEczzK8L;a(z|c zy45&zn3@XzxtK*sdspvh{e`%5|A(N4V9rnpbs)cMxsrXsMEntsSB~p=I`ML+lB^b&u&TA1%_ahA8yU z`)!bT80XK9594smR*8RF0~9**UrRad-^~Tl$YVV5UwChgWoaC~p9n$Z3BHynNaN-H z!{+`#@`=O!n^TyCp=yNbiMS{61cI~De6(c`a!9Fh?}(wSLK+}TOzL%n=Og7msS@kc zI{=FTQNZFon$A%a7-*I|R9KKX)xjb`H^N%$ynDk6%>vO)A+3Z*jYX`e367Xas4PRH zonf29%VoQ+ffW)wh$2EIJyThR=rKQK%kM@!)7kkR3jM*O;qZ0oC-)&@oZ#(PeWt+k zP=OG>fX7^kQfU@#A%z%olnvrxmK7~Kq5YVr5W9oEK1d~{9u?qx&H=1lcLGR%0_rVx zg6hCk&exmf;gScY7J6{u<+m4N_!XBGQy`*9=8y&T{p{~{v%bY3Qzmck-6SFFCEar0 zu(Wa_k>n&hweQ^O+yeXu$#ZC(ZC=TZWTcb_I`cH?)F`A zTB;*&Ft$gV+km)VkoEqH6ZoKe*|oXx`ua*t?|aeGZ=7GESU?3w0x=*Fe_n#O*2BD< zN{wzpYTfp3B}z548#G za5SAgS!l5mse~G>fmlGA6wSdF4><3i?&4 zv^~C6>o@ZqI>y^jp_dHn$2Q}MkNHwHIcmy6Wra$Kcm*h^-^kF-xW32RRC}l0HWs1; zG%Qu)62l3zxf|!`(E8LiP-x}$6_6pi@<)GkCubRFJO!vJeCuY<=e$)5ln3o?k@rn# z{$^Xa>E3|MP510QD>%jUAYPaREtTy#qsdpCL2EZ8^^@R>C7^YWOVYVHZa_RFYIUBK zxGbKTww1N(C%VB&o;kk#7P5pK%yAI&*H=D88-;VczA9-ZR!A__8=3#+h&o~-JfAuU zr4OW%!INB=`%$Q#=sa#8u<AV})G8~;vjYw% z?TH$Y)CK*As6ifiD*^NZ_mdd!?1{N1n(15MNTo$3m?|(7I2qVg2J(ItrASv;Fv%1J zU_IcnZeK#+qsbnDUW@Aqw{9~8(rCqjo60Zp`=jVu*NnfS24rde7D;x^_(L6?p6ab{ zuGf$KU^DmR+|zU?9@?+d(y)RTR*OA2GboOTCHg$&jC6jAhI_gb~Ma^98BXY2fQ0=74iFz0AKv zkhRDqxXRpS8^f>VCm*ub@+c=Dka=g_mTQzB{+sLl?%&SOu=xn2D8(7#-BdautKPBs zeD4?~XvDL4rSn1m__4x$tb4l89)65XvNE8wK~?&}231h@QOZ$`R_Ai3SAVw+>??>y zTNl)JTMo2{y7yc6Zq`{ibKUZhcX55lZ?v7eK`z9AAZ~&q{aG<<(?g{yB^~hKQ;gVr zY`-)3J+1y81SX&+-Wp~B$qACsE?wROku)?=_!Nzhr^{0fDoJiv06j8M;p8>W0PHUEXBjy z8>r0~s&q1bIu=dZq}QN(v(J=$-B8g$Vr{(nl}4rz4aE0tbu-bm;~~h-+1d4yNZcim z|FmadS*?IWK%m3(?W`xVQOOd#G5#}{3d-w;9Kw+U=^Cx(-iaA}65-Tx?|?c_(8uuj zCFOP~=w)S%uH(moR)QMTN&-PGhS}0Ex!=D39Qvrq2|qDPkcKB-oD;-PKKa6>#ds2u zCH@4}IjKh2UDV})o#hQKG#a(_4Y-u%(^Pdezb5=DjykYI_72)@HZLwuL))jH@iF{! zK2PTz$T9%_)45W^yz&^4^aox&Wx7b|65k?knUgVSw=7@QH}fAVF5AfwP6omkWxbjy!CH2DZ_CmvdZ$T}RvD)kT}H6?O!P z2~Gp$1iV~PhjQfQHRQhS;AYp04+zv`R^sNV8ByTe-L_C&{v2w6pb_J7<*9vV@%y+J z@X7(Z{7!3gZ(WcDzRnAc`26>|YnQ^HJ|v{KOZ+}F zb0dHJ({RL90__GRGfKKb_lX74A~#zhOOcl<;(e|I6qv+XSs(VRc(Sssj2;X{lnQQS zeuPR3#FlPrDGQczo$}^IkNRlI@ZYMzdCQCDI8NL>`fXg3L9ac3;~!w&GN!01EvR zFrDbWi!(5E{P|c~zMlhI-{0th_15~`h3oSjv@B>91Vd0O+nuIM>2X%06yw4I1)_-e z)Ynvkq4NQdW>t}hLG6R>6p_sk!1YyJ*y2tWkwuXEjlqt@`C=S@>=@2}BA&3mta3su z2DV3n>=RyHp}T(PpG>FjJ%t0_d*~wj%kZe>1 z($PNZv43e?c$Q=OS0Dz}!CH1kEm^s@;Xs)w)PPVCB4IhFBcrGh$a|Q3b-T(VWN9E- z2$RRT8h{l39l3i*?ftMt{^p?wW*_Xk?4IL~+lWw(2(pp=I>Dzt0oG>RL(DGD!)}!Hu6SPsL8aHDU-J6BRm8vm9`r>+Z6Ul z19vHo3jzgoB@CZ#-%*t<ZDY3+r!VO8R8e#^>=gYC*r>xonLfN)i zYux*@2TfNkG1RYdK#0>5$=myFH;N@lCP@u(SqM}nFNZo=mJ)D=&3|a@Ry1zl5YiOh z)tQU}9hmYojRZ%!bWTY#8!r<;3N3b040WITyYR@no=mLTc?4GB5_TG7zSO69Z;S8b zp%`FQke`;XQTe&1Osg| z4`3N45NaU9ni_aRi3YbK=!!J`FMh=iho0?dt!2S3^85vU`_VT8&}ed`WKYY1*7t2~ z+{Mf(J;EX~=W0cr@x^q*l4AY3MC+z-xs!Na{T(S3rVLk>$Q96G`Q}Y(mZ~rY1kve1 ze|f64GBTg0B)_kpT=gR5tgpIrq7(pL(=(?mcfClUS~ zPV_^j?sC7qn!6j83Z8MU=_8Zd{8<0MVU-0vtPbiJnii%gx;iA;J8z5MXfvYNU@N zSt>OWE;mLS46*eln+qZ=Y?>$8;r3;IS603r?Pces?c6x3ilGFNvR}jg){s@XApPp_ z=DIoJyiWPvv`LXdzyG`k;lwXs z=9e7+!!BK6&uwr_k7sd=`{644mARJIsi)vudc7|IJ?}$Kl1T`AVlv`|xOw7t%1zsJ zIIgb7^8|L2>6>zKGz*#8rviVIS-Q9T1;OL8G%vcCxfxwx5YWt0ei;U7EJpolj;bYSQ&CpE}34OX5arspi`s_Ig!0 z^D_ugDyfLNWJ%SfnCnA65IAuQ4dEQgD}0MTy4BoKCDg9uC{&XEmfOUmOeL>*HB?aV zYG*le3UxlkPQ7XB7ZFD>MupO+3oaJF&ZmOrFABIa<~G(J-NJ4!(~|tfNg}r9@FIaQ zu7zVxk*V4|CO3V=+8@Q`L_nDYRv)-VfZ)r@2B?zg?G%$IP&mj~c;18)Gg?+oKF(Q~ z*>bth^l|k&Xy5+L%+s~J6nN6M6*D^=vhgdF2@j+M)Y@8830Whs`x>B8|!bSSy*U`%R~y zi@oy>6Eqc3wZ9PM;ZPpOLLTy+5AlS@*ZzR@Op(lQa+^O`+(MjpY~h!sI~O%y@*CcI z$;nSj__ZPdB*c=(jT1fu4o`zU0rrPKKv}Pyc&4u!nbf@C?ra{YGv~2x%b)~3vzyX} ztH~1#7E(ydyB%#d=h`2ha|tai^CUR(aFRl#j+@R>NA%DQ2T(^aOB8Q-dIEvX504Gx z&Zl6rTyMq+V^*kix;roNvTo1K9IE4m>UpBikSz$&9~ps{cxRw{K2{Tiz_SVug^i5#({HZ4-jug$9dqjOhz|XudMIt_G%E|_pKtoneIFjO9{{#**`FbC|B?QJCkxb zlb8B+pp~QYiZd7u|J}f$UoHL(3t}5_J4pmm9xUr+yMz1rOX!uH7FHo9Erj+!`#qL4 zuBa4$9wm4thZ7teO^~~y5S$-jChMI*ny%{I;zHM!afZ9o3BJpB1!lPHB=`FzfyWb9 zB%mI=SJ_8n6~Acj7YKcHanUkZSS48o{>xWiKZGDJnju`Z5mTd~gT+(~MwHRDeLACa zVX3d~=tO1)dlZk4=2$Wex2>OY(N%%>!Gd~U)nSv2fhg`hR>U}*i*4W*=7Uv#zh8j>A_3eK^N zzWanUJ;^@yd}@EFrhEmoJ+-DOLvbbT;c3dCy3!GYlN!hRR9xzPlt>GiHA~2*wllkv zvV0N;w>ySqqPlljIVes2rJ$$a?83rIZV@Y-xD}gffCRqAZt-{Uz1x=TY*z+V$m$SV zd1oAItmSitwlQKp7k1S{Sxz7t3;GKoSE@o4lXzSG){A+$38VtlQg)Z<;bpS^9}57Y zOM@A*li<(73z-kJvh8eY*|NMb^((;^CUK(#)~)dX=zgiH*YnFSUdMEs1eJ{u0QsdQ z&L=Rhpn&!(gYUNJ&n!p8&EW=-Gle-HYdZ5_DwAtb;%(i^U;bJV@BvpLlyK5OIY{?_ zuoGNz^HsOX#lS|6_M0+({PSzhp$20%nHFd43ND)NI|P5fuPW4bSHLfx4QFpfj99!mq|YNXR^0 zvF1j#)KQZJ3ZC28#n)%w`?Xz$LZ#B*_32}A8&tImO7tl`+o6PGq2IYLQdG;;T=9)4 zO4~Y}RO63#;QLcZW5&JzJH&Q)Id}bLZDoaj;*3nHU%>&dR;77|;pYy{5DtV8 zrf~9B$rP~rj!#iSZ|^sk7mbIuKucJ{=ssr{2$uw(FoX7=qY$N*S7VMmx)H-{jqnG9 zELWe=MkYW}V0t8s?YFv9--lBg?7uH>`;`!FWGbShZUuR_*gE3VB>yP_p0Og2V>cN> z_<6mh<$7?0zcFsuP0Ohu``fb4Z5j{iXqQ$QReQ$A;tdlFZIba0r-GJ;eoN?%OK@d& z{Lcu^(ZD}+QIJfN;pY3pFCc2N^mz-@)psBuVha8-_{dP&x>m; z9r|Q#!Q4&#$v=&pAomrs@^lv+hZn97|Ni|dY`Acr2n;~oc)7;JWs?3+3CQuF=2sVT zI}khH)fqwR=Hw^c?buU-%sSh-TNf7ci-O7ZLA4>=-v%vjy-Ot7^7=|0Pn)BB7@dp3WlHOQu+@=ha|;d^aK zrirSZ>KXD^6x~GRWR4|6?9q)@wqjjR1bJ}96X|iNlq5!#03qU)coT}XszSVjU>^*) zqJ3-c;kzXRzpKM9Zd#j`P2qC8wsmTLnCyeo@~H{(T)<*AufUC$RWF)%DrU+GY;6l71T1Z)PzA!%+KE*;; zV8dLlC3IhMVyk13uov9#n?U$F%wmzs{!E7E!XzHp2hMA@vDN}ZoUFz2IV)$Nb0vqB zHj{GLlwKFUtycGSQL@O61G{O(JdMKd*W0Kl-m*&U{Ppk?9qZUICb->S@)HW zbA4N`AIvUVM*$|wtg0s1RcbLsVCcqu*{1f~Z4uY`KQTKyVlwpwT17%})X`KX;ugdG zGWs|faH;=J{^-}pTI$u3OsDu5mPdA*7P1?p2LEVL^DVb99)%A%Oiwv>yk?$G`eO|t z_ic!)6STfINh5x#tqz?%Xvd(beZRw4L>s9UttwibfT{_m{)9}C(wFB^Xap3d^TTid z>gy}3Pv#ce2?YH{95m$DYQjre_c$V_{%$G47<}zz%9+NHEer>e2x9Tc*QEGy0EV?qDY@2h;C5pQ z0v!xLVnUER5gZ(<@=4DZ2-ai@{PIWw(LM@nT84gndo%Cf;UeJx?%lyO#->b#H#|}M z%gx>0yFP~a1J_MYFp4=XhMV<$R*DhuiRU1x6yg8sH-FJWMX@ACv7qBV3!B1#MlFn1 zHp(u{LIV1o;EPI{oDFf90%*Zt|$KPfo-D;rQ8eNJ+DCDotldC}^ z{8q(+UqQQjl@OfDMdUOS1bqFA=7Mcq%qzpXyuKJ!J!5*}@J8aWLom^^Sj?*yaNEbOhUXqE@adyH9Am-SXiC;rS2fDL3q-gm(>I=Vd9sWJWb zKp-4S0;h{&slfOmM`h&U=}EJ9tJnVYY-O8SJB!3AoiK;f5$;nQ0or1GT-TQ6L}?+} z^u0k{C>Bb(=kr>^*CsaNaIp}&)?(bR@yyu@lOxKirdvlRi_XLD^dzZ&SVEAYS&>4M zKnYS$BKninPa&}OP}{+MK2y*Q9Wb4F#RWExU7rPNs;ZSI9d+lWQpHq zv{Vl?@v!fN;E7Ww8!VpxF_T%{lFjcu4PIpUKFPb=AdcASGLPTRPAT0A?w7zxi~pUP zuNYLq#YIj=nM*D2Re=_-(i!k_O+UczwceYWiIDVxc=!k+Z=hzOjKgXnZt{@nX%ulW zt7|`H*11&0Q4zz2S9IhEq-0j;rPNF!%ABFtf)@YI%Pg<(`LlKigCA8V3VIxUJ3Qu< zgs#PR6sC-JQob$?qhCX>sa!S|$f6mL^6Ax{g&`C*WXyj#@0I>=oylbGDWn@V zYP%F$a)O#fMDUB>U}G{?sq6wJ210MoR%o9mX3x0ZBLB33_N>E7D$)2ajND1?Mj0dy z5G}nLLhZJw_v!Mvb-dk)fnaT@*zz=(?GL=Uljk6(XplW9vZR;8ru7CJufebX%&@-^ zaadAFQ%3NpsP*|EcUS^9M_e*8KovZ9QAT90F8JT)P2X(}{eWZOH z1}FF{wXuM`tGLALS^n4ZSD$hROQgPzcop!NtN}YCO|Kd9!7Mh>fUlP@Zij_QsG5R| zfgxQMcOWGO-F%dkDoCIe3fSzaun=AM<}-!z#S`)iCft^nk=kG8rHG0?p~EUu!3rxI z#yRL}zLB;0#sL~PdvDMw`+@d{P5?B+_wN2M7s=F;rOkMlNRbRO6Ibn|ApE>!6uY`= z*XDf-#JO3wqOB985GGL?>$9sbBz}z&wo$Mm(RoQq{f4F0g@yYKl}%Y2tD8dlw@+&| zZYhmbC0d7T1TDjL8#(>}m)p!tLgk6-f0%cJA%|oV`PSdDJr&CYS|ww_4s#keo0hM_ z!~{+7@;QyrbbizcGN0O5xkSHVPSfn>!6glcigUoesvF0#=#SZ~7;~ zg*svvA|i8Xvc=xg`qOj9?7e0fyTLE)@BC?!v8yo{HJQA)vUkha?mq_&OM=J|XtMzq+;ddav~WDZlZ2;~N00<|`G&W3e6kVo z3%H=kV~hr(@o&$|t6TSwE;}qNX%X5w_2BLr&gMKkfvjr%_`q$;Y}cQCa5iE9!`3I7 zZHx_=EY!dGgqYVYwUq*I$IG61ynjLx zICFTGV1;?V>BPAq^74zywZ53rv%-yhUlE(bujY%ZOe!iYwD1WWEK`N?m!ra4pIK{X z!;~f!sNn481^>-RxZ9qq`?jkvlE39m5|KY2>}BAyf2rnZB!^--8lS^=hras7{N@&I6|YkXAwlxrw^3XCo6hw16&h)Fdwg1Lf8 zHHUEyI$0Wd5FAQXW`w2ORw0&7^ zd;luQ`js|EfvCU8zs)j0o8%FclRi*k2+KM-6+ig22jpcz2eO0!;gQq7)tDde$xajW z0}ffk#$>220Se?~B36})KV6aKvRR+@fT0lTEHa_l)JP!f0s@mGDIE#}o^bpxU5V=Cm@7n} z^pFQiYN_d zIF?>nQx!Vw@uuvgy6m6W%D5`;V0EBP72?FSDn14uDqI9kxONe6#^^!~*i}|Cp>!fF z!E4khcC*sWM_12#(NqXB-O8r|qKJ|(8yp;%X&k4ul>m7vQ1x|U{ow|~5ICaQ3T-NA zEabVO(H29IExL@|j4h5`GgizEMO}KhI?q)gX(cz9Rk2(^K8O(9|u^V9DH?uMSsfDDDVpXlmTE3#y z!W$3Ox)x%q@5Z=>u}{I9o&T+u{8oKrPiNL~F=e;)Rgz{NU;O^vlDU+$96IZ^wV)sy zzpBf9jftt#nm(?`f>t_>lqz$)Df)<@#U_I{NlBD8O%%qw@R`i&P(_CW^=AO{TID#3 z7iVT8P)j)ToP3GlaTR+B1MN1i486NuU0uZ{9S*s}ol8ac{u;`vUN`3@=j_=?7Nnn`7=u`;6Q-r)cu-`$9;ZTR|-@I*9EvsqrrcMvS zU_ivW8?YZ$8}ol-cK4}eT%XClO;~w0Qbmf3s)rZOJ24S@oG89``sb=TpcT`lRJ&Dp zls79PhXH`ywH4O7f(~y2gh>O4#_tKg$ALbFGRE_`s_KOdM}lgNCc}PjKR5_L4 z7g{eah&FuJScIO}lT%;I3%r9|CZ4t#eF~^$@>uDyr3WhHzf7qlA_W1tE@u8WX!YqY zdVjJX1}U4=oQYOSX(#JS5o)l=MS)Z6_Z%{Fzb|@lTJUW0?@LTGu=Wlu<7Ue!F0ZRn z93*YWFtSoID;=TAu@_+Y(RAaiUHV)$??zG^VLQEj7wO}TEK7;f+5gYt^)jJ_7gTKMZ*V^&Hlk$753%J*D?2~l^dZ9{z;@`F?5ATm&E!nHq93;TZ5b5 zK=4(~rht`NOS36rFzD6&`}=mk{!$FmZzzpxWVyY3*G;?J^O3KedAD@6P|q`FG1p#&q;PIPzlL*8f@`($0^+T+|=d8P?8KHpbx1O)108 zposikF{c%V`8qm{2S$SdHgHtaS|0fcFR|g{S)HC#UX-!3xI$A-y7i=1F?d2$Lq+|UrGpq+q{KE@Q|f9Nm-NJf2- zhh}kteW;}>kI}XqaJ;Xo8wS41EY)Rtlzu?`b-O1v|NK4(PJE!`x6KWGU{b?=H5Qk; zCcoq;&{cLo@V6wqIjl)j78l3u?>n4x-zk=UAcG!&_Ey)jBC4JUwipsc`x3rsU4QMh zzh)=jdGVY%Y|Q%opa&t_kS29xESt}+tT6>Z+gDkEB+X2?f5?MWHm>2I2W?xI(Cla5 zCdusMbLR3sE&2h&^GfrYxBMBGhYy!Gkh)(K{)hY8sIfaBu~H!JL?zN1Gy8@PiA`(j zyUu$DH-l!)75?5uBzwlsX+`n*G0#32q`%|BAIH8INLwmU-Gfpt4>zg=)c7k}b(GEj~1;BWPqf>3F}r-ImXTM_V3Q7_obMdmBMRMexLthKYtF zld4l=xKvO*ho~^q6<%$8@diftaovDo1!=OdCo2`H&{p!i-R3@Do5{aGI1rRub85-P zR)G^~ZQ5gXhYCA6B~~wCe?_nd%)s^?-HIRNjA2h6#n~FkGy)yHei5c@$`en^_7^~}$=)d|+ zHER?U!eFraFeqpw{y~{*^!kk4Yc+|)q<*yyXv_wBQGY{%;!ws6u`UOi+NE?GVw#qV zU$V-c-=+oXtP$Lfr+-d9b$$ptcIl3lQtc*EfVXT!T72%NWGdY7wde zd4oTA2tmnN()6BR_!ELsqDHqpHkj)I@5doQm?IVHIFSJkjpX?H?40_(zWRRedvYg{ zVWPp6L!J+M5MMBcQ=&%u`Y+>D$xrOiF+6Ojz`F<}y%%A@;1Gt1JddewCW{osXm4Xd zSaebeH66newv<@^!S_wjx9fN67PuO$^n=i)eT^?dy%hv)3{LCs!`xR_+dpCbZXWYk zh4e&sK~0UEoOtezuh*w;mu(w9y;bX;Lv;-m58ulvOj0n#YmbIv_gEkrRgYg@u^Gz& z3UJl|oE5?)339XP{9w<`j}2Gr+l^I5o+Yi{NfABu_68pvAEzx{_kbLurP9%^S5FU( z@AOByt)Ff*JP&00K%t0CO~jFr-3&z^QcT3@T?yn|t)IHfRAPJ4e=}SdB4}<@Qru@J z1M}m+*oCy_k8d6?cjVHq{2F-9h)w<**LwBqC4ASIHi_M1mHP&+Ungzxv5ZXV^X}k3 zU)${5+K=wP;1ezJIMR7Z*)1#~J=pzg!GKhQh+JvH&W0Sy6ijc$>U|U?&szeiFTF)B zsP+f0=9T&c%y^^=<1bVvAeX+gxgE9?lkmG)%KD}QjT{@E2M?3EsV2C>bCC$Z!li;? z1ZWP-UbfmEZs-r{+Apq?^h3eGQHCxFZL5O9M=ZsDmzGc(pY2Q9`J(9wR3V?dUrlh% z?Cbb~NDX1#%`A?Ux6*~*#P2%e5F%g7__91rCk2YDZqdaWbh~QMd!v`vBMPMnUDzI5Ou~v#}EN{B>~GJf+f>KTn$ZBnPp^St{7(VF$YFLfAjL1+qa&& z6xF#+U5H3OPahgi;hX*9g+F}fAlo?EcYL}b|KAwMlOVTcOPfCirBLnFR^!8AFfc<+ z!t8{cF>^Hq@dx1B)&5e)o{)?>9}|&)vhEMCsq>|nuC`N$;75B&TIIJVsoo8rtC&t^ zHn(b4-hcBcqq<)L2DPQ{oILjR^)Xa-(Q2R`^Z(U69r!ZP<@1y(jHI07Po*F-E6kg z2<5dX(46s6{~mU!U|Q)ka2#860-8GPNY!^=EGpJ;yY?@9oFSZR@e^NU2|L;4X*br9@^q#sYc z+2(#nHNL0}&m}3o(U?vG{aUTlq6mmprEP}fYL$o+U9;1K_l3rt$GXPq{Vn^K)uTHA zeU2!u{hJ(9BZbm+)BExepx1I*{P%AThIA|_pmamn^C*Z`)sYfd@?#zz%f!wlf7nZ< z{LShzfjRrbAk!+_{tEOy51G<;mf9t+v^=MnL(xljS~-k9%ZJp$z~|;rV=Z?Ko$EC# zOzit82Yytu*8NR^{wMpWy&p?l1D1YsWwn!YnRm5~7)Bzuf)Nkghc3;!3XCRN!?*m5 z8tq}5?$>l`n?5huI7R_TxIR&DPEl6H6{pZM6fIPfPSsM8KR3?pqFBTgYfMH5A`kEG zqCqEc*wA_B`JCN-9lGcNVGToDS(qDFeW=A7y$m{-?Mr4rD&76185jog2=ebLaFA(t z)4va|)qTQy?k-cB5Q6t1#ZP;}`et zKt=4+cRN*FBtpCuK3>+kO`cUku6K-nwt?;njgC{D9r_FfkAH7BZ+5MAw)ZpV$c2p| z>z?-;MIz}^&@p62^rqt{&a1XvyPF#yIbCg*`y=~%?x?0iP5z#(rg6Z!XNGr!7l>D( zxA#8=&=ZJK8k*K>P2l@t3~bdRQBcj8S+h<6JdE>AAq2YtzMf0&wrSy-ua34=oT8_H&6 zd;!jj<1W_z00qniRL&4jxDT1eK|j5N^1XQ$YOhi&{}gKWSd{Q&df8^1wl=pDww(Kx z`SMs?I~Qo%B7Uu`s=BZ+sh5fz*=Su|#Q|03+NbE=CwXIax~I9l-%{aEAbVa9lapI* ze&cIV%W%v2PmSrqT8bY5e|~_wEijyIFD-ObB#W@gX+<#a?xGLVXmQ>|nPqui=7|P2 zsP!f_v$0e#M&wJ5pDH5ZwFA=%9&01t`wTZNu*B#&$?-ui;($mHJBX9(7nD994*YqjcF zMdVrxaRLP+2m&IaoohI`1lq_GR1BmSf`ju%z!N5jjq}}5FbV>S&^uG^3Ntsiiflp< zr|SeN^FT&ro6E%l=rURMLcAtEG<2x@igK=(o}C?XfsqlwTXiBQE+)2jxChGkOv+U3 z9%e)HdLNcOmNke(est?@acQme<~@-TJUFs4$hs}@S9J|D7HCvImT zJ3ql7i#nLg;$%&j;FKhN`2cU%iETASfak-5?(9lM{3Y#D>T6nQ|nW8={W!Qt#HY*g4!se5LPz8H@eQQ*Lc z`a%yL^=}8C#%p0ylD+V&zV5S;vC`BYQF0#t3>LX^vhJO=umAD!Prmke091-zLMvhX z7yMdEuAv;nT7K}G>;3(lR1Edt|6>7=Jcy~UNv8T>>ODMT;aSWr)(fUqVxsz55;M#7 z<+N54Sg1URf3*G_*0D$lpdBUq{OOQ`O&AWq|M85&rXgDkh+qbkRe&A&VQLpY^M`(t z-Y9`;w`9NNfVnIxmF(<~>czyar*a`c@jM1P0e5jn+rl-M9fY*c>EP~8b*_)dAQYV( znDGkyirmhCGodiJR_nl_FwdtzlH#L>afJLvHV}E_G<#xc!zz=BteH1DePR82t~?9H@pG zuD{qp25R2R_1cug(_}78EJ8MGWvQh#VOiQ zt$>zkw{2|UQOsfrsAgUTV5+Pbs=e-Vs}#^ZgF3#Ie`EZ?Y$uvx;H3Y>T4Bj;509!@Yb;O)S1ZUR8ZZ+IE5{A>PJg-@(wJ` zzMrBMIKc+KKwaPx=i(^#0n!cZpd@OI@cY4s!FH!WR*ngZ5ziNg3Q#z=m-;2mX^Y>$ z_Otniqxhk3ydviC>4MOzjYm0XwfO*}1wieI-7fijege*T$iTn=^)(MZ6p7s){^toR z?RP+y%$MPd!lalE&NhW46ZHnNBxXBy&aPpp(>* zT_p&O$&Lld&UW?04v`A6MsPHy*zWpW()Zekv2FRc>kQBDkv!6zoU_F z9p#bCIUG1r!1-^}CnmA^|NUM{P*lv+@8n{1Lz~E3IZ}jf^0GcNAD#wAL#Oj@ncbF5tx?0UlJTb@`*#^|Dh# zBjvl#!hoKC{lqyBJ2(5Bb-mi1@&BX?CF99z_W)FlttVtG*~2$xY2?sOo?+LL5O|@< zB=ZwyRIksjgy7@aUBK6iwSkWs9Js?R2L>rH6K*it1@SWA7})o5h-`4;I{o*M9gju=y! zkXMsp+~h^UM+~3a=;DPwXPi8S2WY`5qw%U_KqWb6mXmABTSGLx>~e>h-K2P`mm%p^ z$HR%Sk2tipTh|)4k4CwV*~$icH11H{&j)b4@=t*X`&ogK$z<()DR`jggf4$p>K~iY zZP5a4BMO|=Iw{fh*$8G#=x|)j9tQo^@nu`a;)Vbaw_>cMRFp53w&sO|O8Tfh=TJ(_ zuxHns8+56}nx*|X=G9T+vJEYz*O%t=dIdiph?_i5Y2csZ&q_u*vC zY9sK9pO{TrfYG4EWxpi=t9KZWgz5qk+;a^BhRqTl>~xsba77~8cAmMmTl6m3tR8djqboC_)nDr!3GLJfL0 z?5$J_S=_^z;lh#NMx$ralV7YR?wY{4Pnplv2<9kN_ZjU=H}}9%0>v^2kdTPJMuEHh zmQfT<*LBVCQ1s#SzN<#BCs7dJ%%L{0<)t{axY&&TiyCgk%QXJKMWizO5!-Iz99)+M zmjr;PlRqmyzL)|TsKW(L-M4az9`F2-T)deM$b91!?lcvflkc2stq z@Z1kkh?5vT)h7y#xG!Epf`2zS_EP5jKdRm`D$4Ks1Er*;W9Tmp0>Vf)(v6@X9W!*p zpn$Zr5(3gCQX<{mFi3ZIOLy0Oz~BGgb)Ofrmh$49v-|Ax*?YV3!ilVs3?F`Y6ugr} z&vDq-zrPWF-|sP>e&0#(W7u*{5#(J|$n(J7V6;{s%2(A)7=d3hfx}?se6zN$zV=T6 zlezmFmda*zRNJg*HGXslwby#GQ3Fyx4>{IPNGARwS6Evcx!lJ65L=EYZusp5y8)MD zGNu^H$GruZflY7jNRbdaEifu&qi-jCCDVq<%w|*0C;@s~x7BK& zbkd&gBPQPVbGC!h3`RCOgqYNg?#7BJq`lE8y1C0%B%-$ z2VUA!&Ax-)`@KqH7lFlZ@-3Tux1?Ay!}l zmH?3clf(@aWDB3q#0&+PQNCYrBboWswA0;cTMZ&n0XKe8Y(eQ?uI zb>GkSJnyO3eyj9{B29XDi~X%`fWoLt9|)$Gh!zNzeVb=G=dl>kSv~*J19u3Wmc_~K z&BIR0PVd*1jtQTtMgurrmlyg)Wl^Y*>yv=Il+X1Hpjm8>;Y!L7eD$(W`~Xpw7zf}9 zA5?I-W&BGA)cn|2MK>^E)ARG-!wuhrX~a)-ONt*juQX*6(3kv+Bf}#Xjgzq`dg`uQ z0`T%or-A7lRXJx-aYJgTxs`v{Tm1aJ24)C#_p%CQeoxeLx1VgB&5vCd_AEY9K)OyM zDLR(V)lC7n!fu-6su} z7S3jc;Zp7GDNfg{H{zFmuKRyZMEc9#0r|5hiSYa4kD!*AV){m!8ZVeAHR>_ozNA!Q zFEHx+fKoA4sRBO3xA-rCnooRn)zhjBFD4&v6hFtb#XPLlWALX8)>U6NDef-S)O%pw z^P7R>9+1@O!eBBmM-CI=z#p~e;wxPHMXW8e1i%Hj?Ho)=y4&)c~UwCxNka4zI zT{XIiY(o?kj6L{zUz`w-kt|_q2tEPUj`yqVYC3)C-D%+=T?|6afPZ^%%%~(_YMK5NzD>F|8}y?<0}$3I+=RZ??}0W4ow+IObq>V zAlmrwdTqA(-;Vr$){qUv6vXYCpalvNAdcgyecK;_k6NNX{OQk5-o!mPR)s3< z``a`R*7U|>)rkp$;~-{rmxv-(Q9>Y-hoCy$a5Qjq6k5kyC8;nNi0Bz`1y7I}nqyM> z@dvBv!au*7A(S3&IWvcCbHe5_?i5Zlu*6wFi*QSx^R3Yn8+Ipdx7{$ zE&=oz_YQ|Yt&&o*L&vTj$f(UTBJovX6I^QTa1)!%46Y$2S73q%uNcyAHVg{7DP`~- zF$agav-81WrTE6Crl-D@=S^-8Dq3}@ib3nDg2r!oYJ#{Oo~g?)_a4|b4zbF zIWQrpi};0?N^PE4JOh%st&8p8)Y6c_KyCM+qL4D`&fL*NLLd-_n!oot2)06B&YIve zu?v?A!?)p36TL{GkvPF7pFjSm#cV*Nov`O$V)32O5${ZEms61|b`gjNC#~(W6ARLM zr`m`;>-SqNi(C{tL&BqsJ*H2?lrBg34_0dWmI;+K-e1{R&)$5$KioK1>jqpWY@l{? zj+cigtg@=3Gy){RGa)c#*d!O7x?eM5W zQT9WJ{Uif0#_1#uJW1YRz11*@p{XKGJ7|SRv+FTeMD! zYFmS&^!|UrCn?<=GdNUZ!f;#aRK3;e!mPT$I!Sm%T|-SIm0p0Dnwt@W@oR}YqAi5MDi@XlgAEK zj)oSsC;CS%2Tr5f-JPzzg>usWEUiSlR7{+a98>LKPi!dy>$vJ*zaxq;Vv%`tet)os zVl)EeudA4kXmZm#BAMYWR=j<~E3)K7I)Tqn#9X(#+-E*`SiU2S);BAu5+X;dkZy6K zIS;_Yseq4VH$-8BXpl}e2KCYz6gYVT`W9ep>9am+!he(-_R4(DR8<2N_6*m>249*)Wl^BDtU@vqg?j`hkr=|oJuLgS$ddj{}&7rD2u|nt_(C`nV)5x3RWxd}s z1x_Nxd%)(Ji&(JuGQe&rFebMHw=;b(oFiRXPq;kYM*~IHa}Yn>dp+OzN@MMd@MA6@~ z`j3le*&L&YN0$?+rEB&)oB;@L$Uk;?3!}=kP?$#LgBU zM?9@8U63HdLnWbQT_S%(G3g|t8T1Na$c!{BGaz~|FBpHdR_phdI18jC z|Mix21>@n8Zq2ZCpz9AL)hdi-=MDyBLcjrLyl7_At^&?U+5fEl3vPN%w`Fc5Q z;m6w$F6r-U1@X0Hhn0BRjdoqO+g#w~V|-+vWOT2HP{e1FMKVS^-)pwBb=j5B`HCyY z1eD73OK4Pa9yI1^{T&nJf1I_QOfWj877Kkn6v4SxOAq4Gp-HUdVh zC3JE7HMz}Y^JG{6X-a#n0vuPDFz8e1C-NXOFo8a71w=h;{X9B(}Twv!gl zHQntO=oS({ZEf`AqdO>tc>V}KEb1KqWA9jf!LoP$k6uGwZWA*jG5uUqTLNKpE>0Tx zDmVv&6`+d~6V0{H2a;0Is;9+iU$X23nq(4URAkymjN+>;`!8uk`GwuoGF*{8oTYK0 zp`kV2^W+o7MzF!lO941YZzDlY0D9lhgz{h9FE()Di7^R>?q!W)_ABO~s^&S8)bj4B zsVP6wyL|^-3h3d&j_SW4)L5qFG;6H+lkjbOq+pPa5Q-KcqJ3g_^`5i1Fm*_&L`H}* zR(QL*Kf`y1T7K zDF`uLWAPia{MDet`z*Y+UDnHET3N>dgL$klTU0bZa^vE5 z%y)m+^1#-1rBh>Dy$rVOXCo)K55$42$<+Rse0su)vL$6hgfu zqSXV=Dmd*&3M05NqUP)bOp`goq6q1LtxjJdXuyHUI9#h;nLge_G8!oe*S7B4#UhVH zwD!;Uv>VH~E|Edq@$1Q#Eo9W-WP?lbDu^?>Fo`*UO4&fIyh$ zz{}EAxc4L_mMwl!IKSG$8P}>&iuqARMxIHUQgao@_Os29)C6}t3Jh`{~sTI69`GOFVM!HKV?o43qi#000}C=VZb`04zC z=jsw0)z(=%ous7H`j01rq&|Nq&UPj> zhyu&C?tGtQlYdRVMbfy{Uo7TEg~Y@j`J?z>ZosRP(U%5hSC30vTSwTp0}{rc&(=tJyUapDdvo5@kRCgD6o zaGyCc_`v@PJ9M-cjSB2iF3s*RcM4wc*rV3xr43sb2;9h^+Iy{MYsd&gWAxGM((S#x z>}XT-Jm&t22*FTI<;kf%?e=IM0dcz?uYRm9Mi(wDM~l2w{^tzh9qgc`zv2V?4VJ8a z#B$-XJpemO%u5NZV-~Suygd|osXaWXDdWeDTKre}LKq#snC- zbky(jz?XhIwmHF}JEf*hw6_5HPkRSMMcoARgg}TK!M~+Qo)}@8Aqc@0pH>07w33{@ zYqcTb1ca0W_KKV~`fil_YN5eT(2Dz!(YI3i><97kN=V6kA#d*-gR!We+gaScI7jA z{tQ$Yw1rZ&jG-gTa2WKKbHi)$pwlek4GJ*?2W{)`0#^_o5)c_O^^fdGv7dPnSix5n z3a9%1GC+@i^aE*&U%6U)xU|YLH;UxpvR>n0whN>+ck14z8O0~>K{4{o7jKKcY>M(I z5#S2CFZa+PS2HG-ha0irtZu%^=bzkrw(W#5L|hFfCxM>FaKsIUxFyXHE7ZTgB8&85 zRwiZ+;@sa+L9=qTpgR-79^O08I8$v3&)m}nr@jRY>7Qv7YN;JA6kApLf2?Z8gLVkI zde9G6^UWq{^8Sk#JhCJ-P2AjAvmcQ-W-)?v#`X{Ns9GE=7q=+jN^Mb6;m;gM!$K`b zE8#TD`TWm^4%lgNg?1cfUZ~$((A2QOH=N6&^ARcZeA1(aL%3I)z#W z=(G+e7vTefaM`YpVG$Ag3Ag>*5C7C+!PKw>MVmhcmRZFh)~~#vn-2;Nk!MZ!$2Y#& zxm+&&>aHS&8Or!WQdur+OA^*39(}kLBi=@8Kc(bjee^+1W20qitM>g;;NU}ly0%S^ zh>Dn?aS>U2H@D#M;R5m{Fe|dRVbg#{yTN4(a@=6K6I(q2A0Ae(vYoDZsCZkAjX0oN z_`i#J*)30EcdfcG0|_V-JlDcTPDiJ~hl*Y*s9JM|TkCdWEvA8z0{-;!r$t& z@#NFZM%ofqBzVuxV?a>2#6i6IVuf_!I-q>O*wd5FX&?_BxY_|G`?(JOH#8CiQHlEZ z{ez$Zye|0iX-w@wC>w%&!Ekaju(y{Jcofv}f-5&^@N{s?`%O~@X=j;SZfPz6nIN+M z{p(eqnrnFtbGG_~WM75KH7;%>9`yF;ZZiA36ui}r3Xa!2*mDU3;-Q=d#zigtvs9Mlax(e zlYf?;&ED1lO60cS6Td!EBQf&qX0{o(u2d)L#HN%?p162^(!%cE5^RTQw&Q~%w7GB9 zspa7RFQ-<6mmIunFN{*$@Ohe|DB%CF0N-_nhIZaHi=32}xE3V#3g>?VLLou2ALvAP zywMNWULRAvL^#Q50G7}`vs0O1jc3J8U~eZBxH$FM7@gcTSLdL0_`VyKutiZO13_Qj zepUP`5}d@|J5}j%aGMY0F#07<#rj%ITwsjKz$oTO@F~Lc8N&c@2MEBJR#Qi%JrXQL zKA3sacM*&Q1ht^ zZiQ%~$wfy(pTpmi>2;o0uK@Eh>!TJH)lu816Sx0NgKOLJpNW(1MARJl@$)mVI}xNj zo1zfJIb1{d#h`Yt(ie5vl+K^=6~cj5ZnnU5wvg##u$a%P;WeSsAtP&ta&LSXpwI6v z2Yd{hd`wM=HubII+d)tOUZxOjiY$R{BFoqCsd2$|X(@v+1!m{qIv~hh;y}}U(zZBr zztMcP$V2`-#*Fn*r~%vqqyw+V{mtVGnh0NLAl{L0?X^gg_q9{QpU78n+St1IqpUz; zb^_l~#C=unY9$=`R9MNk$viDK z^B^h<2r$2*(JJYsjaT(YV3c;KG_=PH4u}B264GQe+uZ;WO~CBbMcPp*JU`5gKRM%` zINXMyyueX^og#3t z0H8gedER5ipBWsw9w_+ArfqVYn+=)1wi^C<|7_ug1D+s2yf>g*FRl=s&wOC%@rRw93-B*H=Wk3STpAJ* z7u|Iixq2wvp@aLPvnj9+?HT%KD(FB%`7}o0dNf%vvmEAr0=)jy57z!&QDaXOwb9yc zbQoHQ<5cl57<{Z#{)nzmHh$o+x)gRNheOk2!?aT$kxa&thRrnU8TGz06= z_A`H66DM_|E~qN;v7)`;+M*b2r@*Tdk0cl*4r zQna)}@)KWN^ANvUXovxbO1jZ@a>%gI;oa|e=T<_srQ2btp;7h@OSDuRe0n}yqVjmh z;~_31&$-SN=Rv%1T}iEU6F?~RoR%Az6$lKy7Y_C)$;dhdliv*Rr(xqgXAc1v*?GOn zjE>+@Dwp|6nhw-ARlIPt!%732Lw*kY)@0lNbW4`2Q9M})(~|iIoY?OUM)>4MjC=hU zHLa;Du+v@$_#QL`U=4_+opj;6W@O!)UZe8J3))#5r`8G&J6 zR1ljmZLXl9p5y9n+DG6kxqszy4~bG_)WgJ+s^I=xZ^Bi6A|hUXmcDmy57(7yT&N*w z5zIvj`zNBCxQ}wO_f6>5ly>8h3OYN<79Ckt6(>WfLP*NY&R-N`p>TMO?Vj9Jg-rlm zskXRO(TI!cxtuS9f@~Q0g6c|Vdd=NFj#IB~v3ZAP!fUHHo;g-gi}MQv&hTHjNU^cD zaA2tBe#$J)_bXf4$5<@NKDYES6s*wPK8(?e@p>@RKfJ7L;H7zHkz7y6DoNG$ZA?_G zu%ygzbEoT;km4eYASID3Fu}?QV0q_#>2N9Fc7HS|wHCl40F<#N@D9R;2-&@_ZV3a2 zMXEXOkNS9Zr%ac7G+d;elbF!?;+oh3iCK@r!HSeOPcgvbkMG8WX$J4)b^D3i%~scu zmN;f8CO8$o?q~cqj`Vqv{(S}ziMq!RUaDRwg7|$5X(&{MOl%q#T0n0nFkA?s3@Xvy zTv!NweFCIRl36Ub8+q5yXi==kw>#qcnHLpLLO>r<9%)NApNn}~eJpLK^a!Z1GkYFf zJ$NwZIK<7NX!N^D0k#NSR)BBmS8Vm|@aJ)-5?C#TJd%UbAsZ#DCUYeM%}gx_Y8 zx+_@ha{mrU0xI zFvice$sytA${%?B=?j$|>E=ql$L!#tAutFPq`+c@pTpw0Fv%ax>9uZ?4bi6d3tXQ3#}ELMT&T%h}kJ(KLi%4Hyn4!_mmL`{hH{ zzNmI_(GrY=t)F2ueYy>_t@ZdO%ds^8L=Yq<5L*)cGWJ=!!p6)>mV)p82R~VMKR-LU znpC=9PoTLyltYj~&)8W+NEWuhX~~P>AAENk!J|?P^lN%r`SGXu3vUOVF(PbS$^8M) z@z~5@fw7pV-*}@s@h@;M6=P$eP*1aVnUq&LAzM|2-8t^P`R3zhwzjsWE{=bFl_xmC+*J0r}zIu1A78p6*Q-?iI4rTcQNxkr1R=(0OYP{_k&9T<2#6tq0JjI?zBQ*Y1 z^n{P9_4q{vGgk)85h7g7$2(sUPMqAF(FVQ!Dy5hG5l8A()Z2HzDY^OJW;#d} zd7alU6Q8F6P#tiPY$jq$U1it2X7MB#o&)6nKuK7_T3J>?n<0aBS2tWf_YJE#+cq7b z(usxZn&-?BdHhcpzo$jz=stAN;UU~K#vfI76|Z+*u}BR-)M;~Ipy;};Tw<+>9Z$P) z@lvY`me$05mX0Z_$aFA15Pm{hv^~&&W*5jLfG7rPdz!p%NUI^El}6^yXG-YHDXeuV zG2*9nB9mI-|1U$2|1idZbS>f^4gog~l+A9))v{TUKq`23HXYsnoBF;kS)_-v z+Q=_)3!rL&kr;kn<>lY#H^#r1$Br9?=ZA+ssd~Toy&Z6lE6;e?01I z|7$r3ho68!Q6877s^v|*Ywex-bp9!gPwWr>r&p9wL20d2?E&)gui_?|Yi=*GrkyYT z3)dY<3{1dbX%hfo3w;1Y`-r0XepeWmQXs_UWiabzEf*YkMqCA63rA)fwlcSR4iJfC zjygbj$}&clVWr`+&_&_Jqlr?jl(Dl%w-w?Nh0YMiHRnre0JpaOhBj_OZU3SW5BP)f zb7%`1(l_At`B7vG#oKikZ!zU9BRAd~Al90qTVm7H3JX7qeNO7sV@hlOvmFym<&+>B zU4y_Sz~fS{6uk_B>fl~+HL-lDg>7gVUNHNVdU|^P%zyt(W~KCPLa|9$qxELdJXFs6 z2^>df5Pe&5U%*&m23`#)n#WlBo2`98YJ?h#_DCNp3tC(viED9AlfSIY#~xC`m+!F$ z`1sYwxof0eYBv7(H6dW`62_VCf}cAlES;J3ZH>xh?2{akFvNz;zx<<3n9~p5!AM@17~=ZC}zRAmZc_xmM3B*mv!_~HgRIcfpCA*W>|6qL#C z0Y4hU`9RwRLCGlHl!I?HG)moBGZV+V#SK>mT9i zUp(COX}qnYu-6sIuXcS=g^AUF4sj5q!}zB+_DbHmc>_i28U^OBsO|T$O?%TlUFred zW$bJ2hP6v}KJ^Dz&36Q04S zh46fDc96gLvh6tlG$NF&ki8*!6r>QB!VWy%z@w1AW;~C1xei_#^VeEc|HQ|vj{;DyHA_)9LZy(>E9`}P zU9r+n_?yH~o>#$)8Z3%bt=IBAti7JC{94{2D)qAHp^SMqR=08=-uNO(gdG_Q%vtsd z)Z0VCT6L@cQY_vMG9theibDM_7Ldk%$G-K)B}XXoa@%-&O%B@SD*dANvPSpQY zV$#WNaN^tzMd6ksJj>a|mzmsBf78Zo#ZZ2azu=$u#HzM$yvs^UBL`RAVReA$t)gu! zCvfuBo&@1ZJ~KN(hy7$~uCp}Gopoj+KAs7wqGV)I`uN*ny+m}h-P#igOUGw4X1(+N zYn1`ELO@VfL8XH*Rc^7Ab1e;>0_kmQ5zw>im6@vWr+xs+=AMz$+;(AY8r)^3pAV#o zCgaW2$LfZG8A4Z=T3RBRnV8_O(TtxWpr?Zmud?%qEa*fh8+GDF7!8be1=X0V1`+|@ zBe+DS#R9Mpk+5k1#kjuznjBbvg~cc%BF`nuiokE};ci|Jl|RMhc4=x#`@LQJxIJ8g zeo*ab!F9BG8sU^vzoe)-H&+}*{MjCZnX$3>X7irI!K~XcD#ENvAds{8-?!zR=wFvB zPqhUIeH@|I;f_>&O%E9X^mN26*fIR`PhIHxUyza57M*IXMTjgfZFl5Kg+~{WNkQe< z&U3yL7bK-P_NMINKj3u(94G_@+Q+I8?K0Xti$oN-xGSYSB7~V!xY;UTQ64os9J(p8 zvvm{AK+B2w?qAO79}@~k*gMt|>F***%TJP;m!?l_Jel~owBE+0wtiDyBZ_^|^=K$p zIZb;bOumdQ->Sce{qQcTU62Ct_-D<4HX%;B^E=4_9a&jfQt^W;P94l4MvHc3EdwoN zRy<1we5MYNLdxu?!=WWZ1ih7UjVC|G{X+E7nQloEqsjbd1g5Aq1JhSG2F3SnuR}8s z3njxgICFin5+7s#GH1x=-pD(ys3!%&f%^bb278&!!b6!zb=@zDp-bP0FaQ7j(H!l3`rMNoIn^G765t4_a=D(+c z9RR3$v~hh;-is9pho6 zpbKoMgs5-_K(I5Wr;J+~72_Cde--u=RVmjVzy@x*`g+PZX3Ip&g@sRUfC8o<8E)AA zM}tivvk4s<3?>t+G^f@xVGyche|6PCW8q^tqx3=J-K~52REg*GNNT0-^U?KQl|WF0 zzaW@@5kcEVOUEWo-AJ)0Z}WXpj_q_=tk3n7oV~z$x7rV_eBu-xoVw_69NRaF9aGDG1>}D!E$9Yp+J+BA?6Lir&V0!y*C?Yod00YT;-v z9LD%J4JbPO-?!I62H7xq9cnr+sQ*eNUA$1aR3$K>%A^39a?odv^y<$+l_V=|UZv%5 zO%+>PM~%bKXplWM0x>k}4NHkv*Pq{5&G&ovJSv{Tq>zRS664m-8Jc2)H(_8w70xzL zh*O7Pb>@OBIqkovFV;{;;f>tKr6aX;(+#3HcNrV6RBkQZ#C+Z)ntB;Uq3q#v??^H* zVE~oKBUiVb*}xR$h@TFxBViP%wP)TQn{%A4c=a{OqAG7?Wk2RTI4_W@A{{PNo8iN6 zB=X*!^3S<(8(pqkY1srJ_%?e&HvQLdn-^_Q;^q=We&%KJNPZz%RzGLe zs+#yV4u_SXK>r3yi12$x5{t2F!C7T$bWs_~)!RiZ`z2P14=?EL&M7KQ@N*15l2K69 zW6%NaL=*!O#$cr(WO}rci5O-zSZD&lCOHIhfTOo;K4&}h*EiSbv^+O+UnX>Nde%AC zoz33g&4t&gKbhP&55focz14Fk8~lwCc#_c!q0zuYAK=>u@O*8Bo@>B> zZOAjTGJbnjmKo%zX3$zRJFsj9@+Lyo&b+kXlW{~n)`fR--mBf;2UEyn;-m*|jH}Lk z?4Q?U!_6m$de5)FcH^4(W;T6D5N%R2oWsQm~cLDvmbbw(yN%f5u+yk`@JZb8s_0-T-_Hhb@G{1}3d5tCWTa7p6FfTYh*-}B!|}7IwqbSMB87#-OcmP3RL39B$=b+*y#m?w*2{1q!K=jGfy6n zti2fK&7u;+8~(KfFcAxAVc`3+-r_Yo2cJa~pa~*lZ&^-rBrGm3?|7iJw9%Ds08caM z=@ucxcmy7E46~ek#B*<<4&ash3R;J_?Ff4QVQMJXVq_DdjMC@eVY3}y9reCmaHLJY zI{v_}%HGP8A>L&7l`D{EeZ2U(ytkaa9W=U^arq`JWhkPn4}^{PwNoMQp!A0%a=biQ3w)h+81(gW)~68_LdXAo2q?Zu+)2AD$bI`Vew0BS{|O~+T2y|P zHIY_=e!upPFShraHpf$$&3wk+koA-j^MuoDen&qgcbUQ-OKT^~$b{+WF_AQ}0k4w_ z_cX-cSI}gap^4Qn;3bK?fOqGY!|g)2Ukw=UM)Nt5-(leh!F_-BIKlmZ)VOaOu5Nwq z%{3v|d)cwZ1mx;mke}}ATOk2x*3`o9K3|9=8vH_sy^*Y`z5{H8sr`;Z$Xl}Mq(BD# z)c!yQ5zbyvDe6~DaCs+U&384=%XoGcrjA{3pNjP)oiYkb))tYYSLVQ2EOW|*7!D_B zMR@V9^$Ql~HJ_+xw#eW~6-FgA4At22{&oRRk_J9z+1bP!g|WFC1owEs`oxSaywM$M z+vEa^U*Ml3ZBiIqZwfa1UMB*z7V>g`RWhPuXt10^$By!>RxbHhyo^H9WcauOXIw4W@`*jw>9{?+fvLuC`qasEz zAlUq2=cD2MX0gvL?N`z$Go5mAJswm@m~i98`}yRLk17JxTV;tN3X~*u zOL;uYyPV~yOEua5@S85P0+ za?It}-0xlZ)BC4(ZgZ+)fvrFX%$?iBsb<=LJ@1L~gSqwk^3Ow5)kfo@71xemg_0T< zA@t9uPl|I@_U*r|3uW`PdG<;;dQRoE1w`fuJ>4MC&#L8vtWaOj%2{|b3)w#sDX+G? zs`fioxilCbwv*$S9=~3q$|=dcqjw4XrtR4e^VYWA|CZap2+-Zhl%{ z3w;vf&;Wq_H!h@78!?s2{5ur;+C0qz2WTO{>9x%Ewdph)3&AsOvs`SuoA2Y$?M&dJ zz9BnFkQBNgM*^lpSYA%xY2Do*)}wL3-vZ&c=$u2Q@rf@05`-YUalCYn@R%{#y`Q$Z z!a_3YaaR6G2IlV85_Nw-LT&b7bhx@M>?;o{h@T@ObbjnyH16S|we-6#WASrWtw%UU*-f+Uy;+ zrhH&Gs9XDmC#fX%wo(@gbSaq@_ui#%2C&`&^VPATPa`y!<|Un!L6*lQPU%3r{>|CH@o6<#XCK{1Qv#T zPwe^AM=P>baB#HELHJF#t(C`Hk5i6se!0oxB2DzE(ahEYoic~4qE$4dt`BQfXDOf% zXq2O6oc8|>o}AsJKRv3@jp*n@H+s`B>&bPjt2ewOdDMsir}I)n5nZPxt?Ba&vBinP zi-xb)oV^}u#T^Z} z`Yio2x;=g@&2T@k5dG^t!=28Lazw@3%2daGe zR)O;9EyX;>HAIgY=b9B2&o3_yz96n*%O1V%P5jMV?T?*$>Co`<Ho zT-WYqY;0_ALbvWxx@IpHqiM2ida$IL104-yTiKV@T0QuE7yR|@A2R6ZL4Z4?KGbj=k==Xm{msYQ{(KzX|F|m z=76f0_Xq)a{kv(lQJ~f3!t0Ah+|%>(2jLEW+Ov&c0lSBt} z6Sm*Yag9C<3IX3XXytE}0hetn+!KUcbG)dWO|P>)9`_?_>-7w4I1@!0rWXp!X|lcZAE62p=;i1bDxZhG*pI4;mdW2(jd#*peu+YMaQ>Q} zPnT7ij@8sp{ANuPn}lj17@7Y{Uwe@iKxZo3rzWM$?QctE)s+F#Q|i7?rd#p%yGEWs z@h}dzHwVx^qovvCu##C>B{m#Z$kw(yQ+ZQ0owWCecEKI;H*X7(|H|-jG1^qP1dkqr zyzvn*;QZ#(TL>d_iWAGmG5?{0bpzYPO$4!8Wsm};a(L3sSG>t zx%ipRd;VE{8GxjkoUb_#%_@_r>cP4Cp2M!&~$5rQO~-P9G+l5pV3$;p{o-7W ze>jX;j+WdPZ>1Xy*J|+LflqjSKRSgbMqTX;PM#CJ#HOtUHJ(DjZ-X-lv!Qmo>Ws&HM{V7LNZGW zz4Kz-Ca+#4E0uL`@pEp24>GoR@&*Ijpp=J%y0@fNQNHw{_fdn#H+E$M$!ha1b8vJF z*PH&UQQ2AX9~-aUn}0_ELfi=*8`qAP)cCQRwN%uGEhk2&Ju%e|i+*6HzBTqJ_Fe$O z>od(Zu-$x=qZhQJ?Dr0Xp0PGd;jfC2Y9i+(IylNPa`}Q35M|1N5Xj5P9E2hM-U~j~p za8WPpAa@Yyp9R;?^UuRxI7e~2aA;ll|HSa1z?Suj+ zWeC6?g0QS-~7Zl}SZb~go?#d|PqM(Oo;5pyQowZ@YrwD(7V(e+1s zMRP!<%mZv>pjP{3{QO&0;lNQ$Zysvph51hBtkAG3^#hxxEK^x5Sf^A?O_9ErPPieR}~cK80Bfj#7eZXLP>G^{u5B$3#(#$i_H`2K4QapoeA#<}c{Byy+oS0_ zH{EJJrKXmacn4nPoMRziw}S)KXjAL!8(_gc>7gtpJp4q1XL1P958h<_RF$7ab%+aw z(fjW}+cq_v2tnr_k6QNFfX6y*_wTckWqq;G^`F`_BN^KH7?J^LMElcyTdrXm2**>} z(Y7x?@JajX7Jh`Hn>fTf-~*%q8Wf3nGg-6-fH+#N&s-^+Ee8KOMl)~-4uEAbJKH!W zWkDt#thS8=rp3HyIJCr$0mHQtN-r%0)`I zzNY4q!*1K#JZ7?Prd*l^*!5rKI2MXmImejJm65j05gZN5aHB<*AR5vZfNU8MHt8+JP&wBapL0&wpg z)IezPXE!~M3m2N2j85cCCzrT8+V_o_S?^2+jnsNt_3P+O+xOZC2~GY@cv!mo*NJEBA_CHbZW1pSm z6kr2j)26Aetc{E*+~OsjXWYb*^kaA9Mx%4v$INJV25Kc`q)LNE-7>loa(=IOblS$f35@F31*hf zZMQSIjGQRNGvqsM3Xg9X{m=TgzPXzs7;-#1#7L0^I$W-1^y<|1W}4an&Pxf1vS`yc zxy1@Gq-UyFQ6>n;=Jp@>IzDk3vSh_mC?~#LD4Ew)%GP~u(R{_Jh7ba-s`w8uz0b-{ z#2y^(NR4hAmzS4m8!r0Ad>~;_3<~zu6MJ*6V?gwyOJy>q@|zW4ju9kOr{!*SluSpg zMXSNwV~&|03#52Bov*`Z=>M6X#R|CRM#&MvVqwuTjUAC9vCWafwHeJ4e)2^V?qdaD zX(Tm6umsJRFy+Qn$l#}Ov)QUWiW8CIq-$%_(yx)f460nZqqvU@bHHbotwTf6P51Z9 zTDvlY&@hD+&|y+Jv^vA2k_tDC4lJ~dAQ(o0eUw@v7SsbnH$3MorbbBQ)O z4x3SW1HDpx@iJ$CY6@xU+Gu(-OyEXUud3Z1e6gRSFE2lI&lMSWgsA4{?r?|C#%+}T zfhYGVhu-3O-=H>EVLKiDrf{O}Iz;wIoS9m(SWBUpA3_uYil& z@%nx5f$}t*Z!>TL7l1R|#u|P(A@UwhacpClxZlx!L|QxitOEL8M9B?;{vcIKp~)8s zeLYC|#Irfsv&7S|s*Z)fis?A);I?Avm0*#g=g|g|O7nmnD=-61EnF)vyce;~f;Y|qlT&S2Tv>@Oq1=MR*@>obVN(9-4|GSz2U3T)U%U@EJZ)mt+pUWAz z(D;8;PDmph+!f9n_=J|edr@k2V!&qPHIW<+d%59m6So7DkU&cDnlJomj+(`<)q-Cd zRedjrTj#0Hd?EYONx`|R_*cc;yc_rWjCQ z@VplP47_5WAb@=^7|h;ew6sWM6-((VNS{JKS@x7l}@ z;%*aEz!e}?i{oJ0q_h?&`ID!*J#xAYK#<*uUQ8aFeD)3mR3$|*DYAp5FVB^xA~t;z zZ{XC>{h45y7=O0f->0ObwC5t}Yv*)!z+{?L5@Q$zh5cuhbGXck>Rgdz$)tG1l)lww1n6wQk2(b39& zItXR$TVDUuv#uBh)`uOu--c%~z!w&%?Hldxk`C@Ka9Lx&wBBrx0N#-ZGqA*N_M_O% z6=yLywV_0shg!_n|A(flV2G=WvA7h9TX9<4-L1IO0tJf0K+)pv?heJ>ic4{KEA9@( zoxulZAK&i&ftmY~dy|voBw_UpW8KPVZI81(>;anEKWyuG-I}P6G-%-(p!GrV-Hdnm z19$hw!8kVb*|L1__HRrls1p}u1WkCL(dn&CEUxJ8+M(F{AG)jlxxsrqrpqA{zkEY9 ze8YD^IF@U;QJv0~m;;G&lHArUwdZrMP3K5mJRaN%NZn8`d)n3vdQW2!F&{ESvIQs7 zz1Ah5K*03YvC|owiV-!y=AM_xZGW#T!Bi{eIQCQE8PxsA?on>&kL|} z*3QSN9eVQw)>YnPAGkBLkQEI|ku{h6fNpsr9hoWv*a$l!yYn&}(ht)oOkxk))r zG|~C|Z)E&LyGRR755}Pm37aT^65zVF@btOLZ+kd`$gO=1DoiXaEU0|tGtBphu|P-K zY*8nxd?y(KlmjVT_ibSVv991lFu0Wv$PMMZj{_`el0xpd5N4dSoa@J~GxpY@rnWdL zS1uGTgN~T%V_=jRW}LbVBO3~(vd+6~4`h+@lfW#v-x>FqVXQ8uJrcea^=Tg#t6weG z20X~{nF#mtY;@X=e7p|wDaV5t%eV^tF+qJ=*z+6Sh%8?BmN9yNb};@hTFMF#^%g*0 zDj%|=n2mv2$gjnZK&N#iKZH~z%kp1B=;+<^661lP)r*n0){Q}A5@ot|>Sh)&=K?wL zO&&dt-kx{lub_}SseVw@lvRqfg;LeFNqI$qx%;zsx0}nrI$J#TaGp^~Q^rFg>rC5m z5YWJUZQYB*;LuP5Mjp&xL}V1BNJhOjTBSiU zpeIXN^Sv>2t2?yLlZv$gad>pWibF7l9~`ruG-j7M1vDTYbD(;~z!xMUm~lyyJ;t-e z>t=x)A+(j^%Pzs$_fFmcS4W0u%fJqTt>x#9s?^uE`Xu{8 z_6CsgzM^l~*H0^e!w-i2sGS|;c1xt^V5zJ;9GoLP^&Esjn-uPOID)_Ep8lOHJaklT znh^RnaVcU>}0DS<^CVydabvoXGWjX=_1+8-9!$=TQc7(Z5huk=?28xfx8dzFiT44 zqD9UYnQVNH)9C@;qS8I^AV;l4jhWb_UM@-YgF8ChAg)!emoESp>-cmRmh9nR3J9eF zq=dtUP{X3AhKuaEIfiW#+@+>(9((SPt~nA*sozEhFzqm6(q?$EC(w|ND35%rM);k? z=jy<%b0z#QV|&#>PehoFuYOQ#rD7%rH3b;H!!fzxqtO1-Q#IN$ati8AMPU@oK&3Tv zPNa>^xm0KGAU~$5ySX1lp_6Po=Ffk-X?H1#>thtZu z`C*+DFvAh*y$s_!K-NKqDT$c{fe1?Zdzm;8%E&~3*r+)!xAD7WBkuXJtrVCL>e8mV z(`qGC4#!qpP6$3Y&FjsCcD7!2?Yohp^BJ>czxHULJ#SKpuQ$3uL4ACcqIg^%(oQYv|Kw~xAe5KP)IsiW7=`)?!&s5z3tw4->!SxX*tov zA1iTCpM?g(CNQmt5pSu<{AyZ&+d2(>2jI^VuV_^aeCup~7&cEk1Ijxm0R-Y1*X#O1 z&p#c%1GcanL3&`dFl0(xrI_g>$M|;`1F5nn(h;txQDsZ%9w;+D0N`Podtqr_^}$nV zPWBeMvRiU;+#8JT&VfD|ybG#pG3cKBxsG_SPWOd+K(s_tuw0Fvq+z4}Vv&e%K+MNy zV|gHSp~$5LpyI?+l9B3F+jVZ!0~)fV{#|7K_19;Kg7$(W&8q^iH~S`u@_yr-oeUFj z)h=m(|4sccK8x5j%3@Cu{qytJq0i~Hk_47aZdt9MXuh=9rz3oucGD5{cR^x}q=SbY zdAM#_Rpp)28T}`Si2ExTTQv}m>^)C25N+~Ex$wh~?*8kFo51C2E2pB<{oAMWd!MGg z*RnpQ*tLMcOnpx=kGS^D-dk}1Z)NSVM`8cRAZ(%fj^&$!eq`V^8cXoJQeJBRLAMZ0 zPmq7NZAi)5XxekKf?uRn5v>W5@`G99BO34@wg*2VAnvn3Bdzo7F1{R0XQL7IPDSk$ zE#d!y77yKAxY;iGssjk%u>+F%+Cmffgpl_psi@jFYOcJ@ zA@tBx4I1~2uwopnsS5URNdklFI8Ve=qQAQI5VQ0s_b5yeNxe$5MGf_v6hrWy)AhB1xTO@64{K_ z{I9eJf5+n)Cr*2UdNW>kuauvbCYaOFch(;VJxSi`GDGU~z&?ZDvhHIqgQ~z!aZ}00 zi?3Fi&IFW}>H-;aUTuA>iw51Nz%FKOeVBYAuq?uP*B`CLM&h@AP#H^5%>$$H)rIMw z@n4Hr{>wXt*S)}3_sPJm?x?w08981DQ`+?rv|dgUffTa#uirCk&Mz$R@IEhto?R^K zPa+4J7Lstd#gXhDqcL%Tk15DB9&gq^Sh~(()OwJT_#tb7?EAP%W_Ng5xca@!`ToxU zX41{q8EhbA2YS^%aQ^$$_ClGWzlC3nbY;t}L^I`(@*|4x7oK9!1+cGlqw)0k_2!ocRQcRrfOEmiZ%lM3_(c<E`uOaLc4tkM%vJ5Jw_&d`lA3QQU}DFdn6`LqTC6zxU{v=64Ep;2bZnb`))p+YaFbP?7)BH7xW zepNA?ejw3Jq?DBxwtb6%xwCAa0Ezqtf+nkQwzTg1tNTqK{W^0;$H+cDp$yVuvQy+w zA8S+ZRvxIUe`SE75@s6DD?NU2{pl*>W=pZeDFu`>YK*GlQW5McsGu!5a2N=;tVsm@ zclYlDrQI>%@6Q^+IY6Zd=$N|LuM&QoB(iAb19gBn;I zF`AtN48kd{LZyz|K?=I}r-3HxCNrDUP?eus%^z zC0Po-^-+yF8Z~X|RL&@*Aew&?r|b%LXh!BY!~x|acB+(|WTer9%^1z+D%u|g2*sOK zz9;Mp7p|Kdlb+VA(c>x)rqumP23Qh{H)+s7^5GOuLA`8l7|0P=Fb5p8BjjSWH-eEe=&Il~30^=Cmx!_Oq}TDv8r zexA>0Rv`tpE)K>DGj&aHauz%`A(Dntt=9TfR3~a0`1ZY=`V+rg%Bav%IEem1+;ZNL zd};9 zLJWtesX}lzF zhpVp)95Ih!@#hd6L!*4Te^`xF1H8)zpWcj=AO!XO_`Z*WxW z%=WLnGumAwS?VXyh~Cg z_OJj`VRX467kEj)^>JD^n>^(zI-S$zdTN z^vrNG#zhks(&nRHC+bVzBmEuffZXa5X;t~n^4I^JqHA~=sQ?(+Bl|*Un&CXN9)^sg zeF6~-G5JpnqaskN&B>Ou4!N}ikC)0D z@K+AwLXR!q%Fs||A=O_$0Ckz=aEBzhLM&z87vA-|hlSNPQ%IKVz79`xxi_oj92|gu ztc0><#szU+BWcdJOWyI$NRFsV%P@t&xC#{lcf}Z?msM*vL{J+3BVCd}qf)hHCvUyO ze>I~LwKb0H3r_VaVS5L+(zdGN$$rvgk8I)-mi=ZIC}3$7gO?XeO2%GvL3o&hwWC#J zW7cDE>)zNg)r12HHK4VH#g9;{0M%-4*M65L_MJ3*Hr@wsp|6G8Ze?&my>a{70&qZ)tX6r5P4+$dC2pJDL z>Ocb7S5GEweNX;Z4nDN7(`e7h?>YYWTlK_faAb4Hz)?SP^gxxQ8Iplfzib}Kjnm#N z%kSeF8kK{YjVjjQW?LTKu?AbzYq?Izzifp?HMk9Jpi^j)uiI{q4O*|(xw)07Q4SP= zKX5)%GyuJS&Kt>qZkEB{wMx_uj?)J*L#kSUv8YCoQ}M=5BX4T87J|Z6K49od3-U}L zBqEfLcXGd2#b`Uo&&xpqB)@fUw-tYg32^`evMbRkuYKDY)OxS*$ z+0bhRqE^`bi_tUd26Qf>C2v445Jf`>64X@K@spiYD>}_`3nq4N+{5+34q+5S2 ze2Lg$Z)lJpp^r^ixML5X3-|Hii}Rk_54mL#kv~GJyqTU)I-idJP|4#Z?7PiC0>WZ7 z_Zy;>6|73h81l!W0Iz)d0R-En>#}i`&p0ty!8O@0AJ1n=m(1`qq3kmSst8*Xy3&+b zRy;DG4?xdEE5koO9#&qfZWhQ9ZxpuajAUAhX z$2Kq)2KL*R-$Po{^B56Eo-*0qVQWaG15=y6V9fOa}Pv z?ZAw;Qx9k7e1cE&%}qeL%CnPI48TKJh8CoHJ|89SaQITtHfX3M$a8JJi%8F^imYY4 zk(us2;Y>q)uH@xfM~JHo$s{agin|*iv z8#b$xGrMN-xwV*A0g0}7VgD3DTWNEP1jF#kd%x-*HT_7-Imt{ z(M90kx}D)FN=_=4^F?NxYe8?Kb^WS4Xe5cLFv+B2__IgdJf-R`yX8XVk{TmkiF#R4 zda^yB{bm#Q*g;6A!zONTFUr~YRoihooMsM(xWuv$k^6G)tk{nJ@2hyUmr|$_wsI>^ zRj0RPv&_g=bfH&9G{xyH=Iy*_1u6~M@XdxhGS8rlR&^jO)g%esgo9CJWBNE+1DO;m zz`}gtg#w+xmXZQ%GIl&}BhwVq!GbMcXfZ%mw{CCQUrUIBqT)}u>Ujjk(`wsfb!_{7SAPQJ}l#jF$cTFR3MA{wzP+6>N;(sDlDfr2bE6oY_BTu}k?T zd@ISWF{8S-N|Lac8TpIdd;IoD9IeFp_HCJ)Thb@|G4ehffcJSco~`f7dD@)5#Ww*) zW$iVE2w>zSC587VfV{H2<;PJhN6#o_8$`Wg2-X?^XV09P)!l5do4a4ag~~S2dvuyN zj}gZ`7EUCRd|P9>iSaslR|f6_AHm}6byM-!9%y^_<@x7p-K4#LD^`!GUJ;mHD8HgV z%-ifnqZ9r6*u~omO+)Hv^#u=PyilpDvC8stCWkO%8Tq{#Tm`u&JN6m7 z@)WK<@$H-myqeM)nN@R}S#RIwnSFgOPJOE`(mcPRV5Rt-zt+)gMv(>Pf8e?vbJ!z8 z>&!{vD2(O38tXA^G*k+Y*?)jT8F9wDyVI)<~n zyN{mnVA~%>UqbyVWtN0@{ABTCWPMU{=Se?NJG$fZ`lsL*j2NhIw~5>mX`dtx zWUCub{heCfDCl6LzOxpkuLLGumc>+oMN%|LIoZyJa>{EakoUbFUe~gAmy|$}g#yy0 zgyaZh@4T+|D&U74Y#^32K$i{h81(O^JFBupd;rQq+Y)|^!}!^{(BG?<9dxkUZEcoP zvktXv#+lxmh_frM5D{D#1U4?q1SbZg*eX|yA_+bL7@T{xX6kuP-^UmOm}kCVW{GLO z5=eX1Wa4!`F|pHnVgRxcgFdJ`ssXC(VOtQJ{U&W^F`MuU!iTp&NHG>3HMVpA_w{;5 z>TjF~jE`$Vge+aQEciZ0OIBf+C*sEaByR+G315KAV~XG~=9v9iBMXHh(9h4C`El-- zwC+Pf_SB4OxSJkX9TCVY_+=m}ea%$(xp_MT0TGz-y)+kxG}Y3l1PmE@A|;o_dY=r? zE1Dm4qUN>MA?uAB4huyBohmY_*r4mR1++LIu+{pr6T3Upoji_)X|5603wxGg$lZ2r z?rClR4oiOBd}Bsh%m#hcl|lM>e++6z*i4okz~}&_6Vp~y@CWq7K#kz6$8xLN{7<+= zt^tw#%}t2h>1--16wY*=QDn3CYw%%9Erpc3hez}M-U>qJYk>SdbSh-MtDQ!==F0(z zUfTf@_zICxW4;_aen@j`^)zat;Nq=nO2d^zUi<9zykt85cn6VkKKrORa~8=9Vl?^g z?7GcYr3CnXZG_^T>L&aw56TS_mPR~cczzpM*>uF5zD<-P`FQv`mbbuLP|B@%qWXES z;=H-+9^3tUtaMsOVLvezp$Ay^;Z(z3)gA*kDY{ar?J&8?I<(wq&ARE}0%L}c&%7W4QK z^KSW`xU6XEt`C1G#A)#i_2NVrx(mUtEOKYPxBbm5EfqP%tiwA>wek8;k_Y7F>t9sgaBe-bc+ zBd(d)6s}7V7}*YxgP-;tG3FH&C-1&TvQ=8c5xpfbRz}3U_C}5jU$C3{Ma4|Vf@?4> z2p&rL2D#5B{%hSE0x3d~h<0BPtIP*JFs4{Xv0ct?^}yl39z{Kk6C0a~4sy?5himkr z;GG~-oNxyxT4(whDj-oLg%@tI`(lWJ_}HGa$5fw}xrZB~`CoGViR0SydlX_34@Y_CX_d<&0L8@Iv=B~z zn9lQ@ZuP}Kp(#f_`~5aQopQOGf}ODE`D4a;eY&iwJkvtQ8yLJi0HJ8x&UV-_M7*9M zw$*WxS!BuJR{_PlEvRfED`8b%Dq&B?rEXjXZ0J9X46rU|3sP$XeFUC0Y6de!0ve`n zN+oKMxg8$U*WOGtim%p|!q9F=o9e~*-j0vJ1a!VvTdo1v;arBKjW%EpVuVG#m3y#) z3tVPgY!Nch$-+do(wb~kQ_Qp>VXRagjLNF4^Y^!ch{k^I_N9a+Du(N-ExI%m-08|& z5-mo>qK2Xe+-OHnXBzC@-R)!olKAriBioT+^K^i#QW{yOm=S2q2ZMih1L2mKc(q@hc}S^#M=5cGyc>2oQHa6y9(T>zJRs2|JiPm%Y|h7e zlmi|UZ7&@`SBMYz3;HDs2Y4)6DFZ{pGo1_9JlsHQN|eo7!*lq<*RW-Dz+v!b{#ZM9w}rO9u-E8Y}!7 ziyxUw4!00xS_1wnM$N}0vheWtUkl_hLpeXUs2!4%U}4D-mLwnl1p3@k@1JWtC=7hz zu;N#qJM^ozW$YVW1SYOVM_oEp5oTkU8}8X>w2-EFjA2wnvs<{5=jSifEHsoE>xzsz z`!?<8Y^S`eZteu23Y7k^iXl-1YRdXa)#Lul+g}QUCf1RW^{d7j5o3s_{QQ$YtxS(M z%Y&Q1SC&}4$^iY3RRU`vali*2YuItY3h*7KI5dlv+gFU95fymJC>fT2c$IeYJN6Uw(ixah~I?;3_jGbHSxS7Dqt$1~uxs ze3ZqiOUjf3{wy&@hdJFHoX?>ovO6q9b9yUgsqIl%=k)HJwzM6xJ<_gE7f37eApnwN zEfk2DN6gcD$xkF@8T83yx{DqG#+46W3J^I4!%XUzyDS{E<{+V2(OZTJ|B}91gSM{+ zXUvNxD3wn~>Vo?erD(DmrGg{wjyZKyGODT$ss^KuF)XBCHQm(WDV_U5H#4qgt_B1z zb~q%y8fQA7eqeV!p8H^VV8niMZ$wG1jrk_$)((UqvF>QXrLHbNSeC&(bIKX-WN1p#o z@5TM@D*NzBithYZsxS1pR_@T%>LUL*nfiMn zM8IR*A>S`ymISFt10RlIB>8~(*Mq=?ZG&P^YP{+J#P}fhl^{sza>r#YFk8Qw>}L&C z@jDIgIB7{MqOYo|Yuv8qT`tp{l62on5PL261(&RT%+R7r!v-3Ls#vT(8pVEd%g(F6 zegD4KROW^%5F>B&yRXw=rj#VVk+Z8CFu$UzBa+b`}&S4{LNT7biG5c5@3G_wMc9m5r zjue@s4?zC>aZj!W?$-Ab|()!W!}v(SuX?5O3`Ouh-MFMA&brl zvBji-rEh}#%as0V&^`X?UcJQgWbnP~h<8KYe7}QXi-$X&c_E6ZT5hItAr5!Fv#pnx zu83~{8lQV>bhMI53@$YwNUZBDFXw+R2@?@{Ln<%(lXUw!TDrQrkE68{ zG2oLE%Jg9~ut}sc(p0iPr3|sRF_Qvc=;H}iW$75k7F~#yyIltP&VjZmhOe>Vn@npw z*8`@E*Uh@4J5xCD8mk+TyELpJ-353SwwGjG}#zI1D;Fik1+&y0D={K2OfWKX^@@tQSxnxyD zjEBf8o38)X@T_|hiKZ2)tM?*NJS2*4LUXi;Tu^T}=d`Cbe-%|jaB{VqfE}67?u=8U z=f=W&lclsp3^FdxxObppI-Y}617Z2#*D&okw|Fbx+f1En2y7FsXqobS1pTX5>)bTu zsP=Nohr*>)TsLOGi2dmDh1R)Th~V+MDAWkevju^lj@)D05PQg7)k@g_W#dXv$@Z{# zzvN#fP^MNTvL{}|CacK(E-5Ukw4Ny~EoJjnl~W?Y5WB8#DQ{};JMa#iARf6Ah}vec zgI4%3_ap!4j^Fo2hJ@+Oyh09Sr=B?|S(x*VYFzHPxHxebll}@`alC(T*b!#hSxd8_ z9R58}dDVyh)t~W)uA`}`2FOKvaCLfK8<+1@nl7k5>F5PEIFfkDciufjMa7~%F$9J% znKABk2>5w5)ctn!-q`IJ0r#mVc$5iX=M>mWYry8ac?B&nG4I*JauAAlzit&-EVP4q za`FSHOl%Q^>QfLP%?^!lFyqvO$jaIn70}@%QtvmQO|zi=W~(G?BzIv_THqR*l(%|1Mq3&!!r-t6Lt?(G}Cw%*QJZ`6(=Yhz{6-qR}wxP@!YPfs%O+`3MEIP zcfxE?nn`SFdC?~Jo<;t&;qI}Qqr*IMwAiXkSD4{&cB@^LPBoVb$S2&~>{pLR)gv%| zV@^ZQ@iBwXfo#Hd`J74i^W|@uYFOT+0L>@B=63w%?f;JBMVBu0#Vm}{r1e|vckFvsNV#fM`}ip;yV>F4>h zh~{?U^K#lk$GN<0Aa=~dg&*l1xFT=Y zS6ER8+nz2h1)NiWz z95df40KLM02%y(&UI$JuA&FQ)8Ywk#+!fUsN>cqq6-nNpsn%cNf1$Mr6E)swf0&K7 z&7zGCd;G;ukIT+e5~p~28Me>vGKtOovrTl43K=_CXm0MC{-hR!g~|W>94uA;H}HVq z#@x~`nff;i5>HzwGqbgtdoxespz-?h%)(N^&$KYdI>0aeQ*o?dZ!d`rO3PBV2RJun z1$lNnl*Ov3x5VF>`I-G~=|ZTHMIJ3)Vi{aG`}!b2p?`P7SE8DEw9UvTve_|j!g|Ga zD^lj(?Gsa<{_EZ#>-+xOI;xSh;-*ylz3zwa71Kk~rlt#dS)Lu=j>FeWR};f`M032I zel@9t?mNGZA5Xu}l-f5>dgwaTk)*BusbHCWky0uEeY<*Q7rz=wpk9$xkDFwe$jIJdm_N9nK-UsOVeGr9!A zMkYD3190i{VEXY$%vM1TC2P<2m2-1ey4AeJLrn4>8<1;#@Pzrsa?-qiKS7Z8FOtIW zAt|#8lAOj3@8c4pauH{5Wo6PrwL%+p(uzt^F}mGt-aUr|h&NO6i0s&#fIq^2Ht@5p zov?Zu2o2uEXEe9C{ft5I*MZCWUpr0un5W&{x#65|0@H9HhS&GvixTm*OEzh2b2MV4+NTuk!+>y?E&(y z44r(NX)-+Ern!lpLe#4!44(%Q@H^R7Kmq#O5BPj5c;+S}9Hu zyDf|wuAfqEhmf#unBL$z9d_D;d|jmKfBT{7xmh)B@~0(b@AIQc3i0uyqa!iPZ_z@S zpo;ul!#q;a~V-iL$oanLrf*ef@6JSV`WUeA0^>IX{D@V%#9hCkUixlP3~e zfcCuwpwf#a)_8_jzN7 zHx<(nXjia4qSnT#YQ&t{xbjwE2Jldk3UTI_jq@*v(n8KUpO;x9D1rfvt+U6EP$ca4S{XI86$3@;F;_P!zx0RnU;kse_qUtO`%W_z9)@NYk~U+`-kui znbRtVNt(j0_SO!c*?psf#{P%hSXZWMZk5Akj<~;mN#*T2#Ot|rzGQIoGASq5<0@)d z@teL-i;AgQYve=4jW!O={oj8PZ(hMPl0s2cz`__-`Ds6^O3QLXrQi9_syih;Cqm(D zBO2fJ;@B@0VOf_X}oOu8u;$PSZXZ*?L8;UzgCo1O?-M6 z7S}gz=f89x-e9!!}U3J>G=3;JRZkYfJ(vas1aqspIdb--PG>y`=K^^C-NQ`WM^PZnE zpo)WoH%WlS%!18jWemtL`Rqv7;|qxv)_lj?CgNt&wyR z897|SPPFta(A$v%d@*)wtM)Tf8F%+~*9B-lg$sZFU?}Zshg2^5%_}EfYR3nC@8{tx z?&^JFeV3g0S7)9Wa9bGQTS>t#)tEI%S14w$&*b#96b4+w>N@D#H(ZCkj@tb`A&5w2 zS=ov^_|qX4AZm7aGs!gNunqw~Y4nv0FwJS~d)7&siBM#fkY3vp?&RuZ9m6+43-;mR zn$4DTuLjgE87z!61u#CAP4l^^trwu+nJs6Kp5kK4K$ZSk zc?`HJhC(AZMvZ61({>tE{{>vmcB0BR!R9UEf92g*15OqrXgdg>ZpHqBu&?vw$1~P$ zh6C);@ffzU@MA!2htD`INMUtuj%0|0Jn-N+k{Y2W?AR8bqI2xG0Zv*+b9;dlG_=Or z4c;f5J5PG(4B#nDY1|Z~4E@u$>8bFbNVshZL{!9G5$&&Zy^#{P_==Na-@8fdb0Af> z8?k#`qLf!s{fo6f`l3fN;s-!{WSMDSTrjwICxZ*V-78vJTNC;zFR%M{Vz6^Z-E9yr z+5-fPpZy%bp`3oRmtHkLt0ukvXfM}yz^SvwZJ;ksS1!Dy``rl{zoMT=vM`v)UW z8HEdnad=oeuj(FVqc9|$WHQ9X;I^xJLbpGtaf}wc8~fq^xd4?Xvc`zAZvwL6+9O?l zC(#V`xQulzm3;n9(8ctaz+AUK>v^xfM?0jb%f^N`V;gNHwBZac;l1$lsWHP544mJE z@59UY-y*`EchzUSN&T#44W6(Iv$H+P%Dm14>e@fE5F_Wc;#q+!*!L)5m;$p|ly1EW z6x=7WSb_fIA{}NTH}H(TTg*7$H&xv+jtOC(#g6R99!Pq+_K-0M`{QVB^UCuGzRhovZxS}yt_}g87fYZ(pj62Mx@V3LO zPuCyNmuaR&4ZEaEdZ*ZxC~Vnszbg{`Z?u;?@3+8bjUdZC!Orj&BrVHq%COWna8?jS z03uWLHXI+P_uSg*X9ERGOQH}mVCmzHlT)8vd7h?tzG=E1hm(cmEUg17;p4xb*^{{$9`tE!t!HRfR^@(6;9By76XqTprW#O7xy?` zI|H1r+qBU|O(YK43VpmWF{lQ7V3r97N(>04@eci4G;P`FFZm(Lt@T%rA5YEP3y*yx zldU~9Y*;T$5llqCWzajWb*Ibr^yocAePZ-`99`VXX3f?ucEbSz#MpwvGC77=ae4Kn z_h&+W$O`hR4h{^y^ICX+-hP>k90_KNS2l`mZv(PwKQ^!Z)B7c!YK#E%0POpH6{j<~ zy!Yg{74_n@S@*Ye&b)5+S`pwcXOrTiS%agH*hqSBOL~nzc=Zv!1MYf~2b+z)fmb)k z&7IVz>0hT6&{RvSDc1b=P4CNt_#$cp|I*#7S6-rQHeO&{I%fuyKI3W2*s1-18K4WY zr=$Ey>~qZabQEicp{>TPS(`w@z>K^tFP%tGp`SQopniW9 zMODON#Ix2@L?Ro`v)l1;<~rtV<}?-hA^dj@l|G$mK`8heE!*RdCu>VfyY=(D`^M~% z7PeU;Qjov4L1hG}U3lTGS7~8`Ml+rab<7 zO)9y6LG<$LhUbX^(pcx`s8N{P-u0H80blqoswuwxHzx7FhsXuMfN}LjvAHwb&wd9x1?-f+#$< zzO+XAhs5kR7CiAOel^@s=rh}|F%*&VIh88XxrD)v96%uar6%*SC^ioVE_ZG9A8 zq#dPlzfAhEsr+Zq-mwq3imti#Zd^W|)ei2!qu_UDjs1WHd+A8osNPT;;W1e}EeK}| z>^v$9J#UoWU*Q*g#t78aUDMhd1k9ANV8I1^#QIOpvu+R{_QV8{Y|m=@Y)?5R#$ZVPJyXVodqG+Q_aR5wkRPLKFl$r<{%MV91Wp2 zpIoNBl|$2SCRu=cJJo#0ab}tjC$gCwGrDOv6Vnd0?~ReAoGHe>)C#ikYrXtn^l-FyydctsP4D0bZ=sg zt1_B5Od(6)Ot-7w-@fhUu`Bd7EiV&pEG}M0kWeeMZ8l*mujT!w$yU?Q;M=!KWWm|_ z0e}|14*|zYtk??_uU$16;k)6KLDTcbUQBzOHT{xKkuS?*tL@v88Lp2+cah!BeG&}{ z9}oI)6IkvOX^H;9C|))uBP+Eh2RJ{IXoy^(@fe3xN9pqqYrefzukwI1IBbg^9bfpZ z8L(-)?rT>}m1`EFL+@P{j15WBUy=ogXgph>9gPiX4b$NB4-s$Y0q46jxL@z*$G;8X zroRm!3*IEUbggQ6)VEy2k(_!mA${}2V&!m!IOBZiDMT;cOWKHMBKN+04(q8}-@bjb z4kI|t==I39Zt4H=C~|wJ>H@T7=?iW1bUv#dWJJ`aDtw{sqmE(_ebvVVu9R=Gm+D$)&QL)sO0yd zKKF3X(X9cX=+-o%qPEeox%b4ST(ZLbFn`7Z08=tIki1Nlk6b2pA~}(eaf3mWWzVwl z+#9f}!{MKu7E2*S7sk>c`)iq*FaLaw!Z0^TV0<+*pi&@~XnZw`JX`Er)9r1ezg$N* zK9iQ3uz=CrqevNj`FKVQUFe!ksHw@H86lCRR?24cZP{pU*> zDC%fzeSRJ(Yc#;3eIYTDXE2%$iP>&it1<{5sV3>aGJIH;kO9Kz{7LRDOX-dAK%$~c zpTVC4Opg`l>)3tKoJXI;KhNiuOG_7}u(qC1om9+x5TYHN(h%u2^+W#F8(!wyf`vt% zD4+#6px=|s_oWyf&uS-8GmB^09Cv?gKrUk3e`i0NDu}UNs0^Q~5SsaOb~`a`;)^V1 zXJ@q%*=-@U(+pScezljRF|h)p4?_w42{6^F7Rgy@G5q(|Xqg+`U*+-Gy01-TcrnU*@xljF zLvZ!1j8Q-52BqpRMcHKk!ELnWE+F71lFWM)ohbd7z}imIm+8FP(a*PmcC9_`K&~FE zB8UGDe*S}yg&uP{RW?hp75682;D5wCyz7^7UKqeZYw>`Iz5K|ecmpw zH<`2fD)+%Mv$4Sd5w*BVMeg)vCt*S%h5zOT|J7-*9p7`e4`lr?K&LNTUQcjk{W8qg zX{9&dlO{{Jzk4S@(HAm;lYsYQ|LdsN3FYnDy~_Bfkua^z4C$L1cb)IbIbA;xFqqBu z58xw~EFqm&5S=c!s!R!x;`Mr!3n|Xs+;^_W^}7zWB+^NXLy`91zAGrKXGRT&$(ti6 zttpG^qh_VWVMpOB2~^;-DwbhQ1Yfp?INo>zty;kO=9wXLPjfj}tiWjewXKvXP3zmYjWRF9{dKE^5SHoL@KmwY6*V zV6z46nqj-GwmiEgMDCbU3Nz5wRROCf) z45nu-jx!qOEi5bk%+py`Xr*SVWIEt?lo zzCH-^41h@%2tf;vbIp!O7fkW*XNW?#AXr}P{@{?7^7T>srS5{t>OgQRkGYz(>KhyM zb~g}33N5=QI`zYih5zwBbY?2OCR}!R++uwfsh$%qGLk&LexWEdxu}RYbZXD!QKEm| zis@rC(4(G}Q4^}V?6JsOTqs57X=PWU%|A(n&G80ZtX2xvXSnGcZC2{6TI-t9p@z$G8{7be?hq!M};0 zg7QuA-d%`ygW?I-}&PSP%x0aRUF$hJ4l z!t)N#r8kcmi7R#Xs{pO^S<(kokzFb3shG@~ldzAlli#3lnY?hbTU=I%o8^Nrwze-w zfAK4%E)-ZSz1op}3=lWmAY&nbN8!&cL9EMb@d-|o-ebKk?yRNaItc{%;Pif(j6=kg zYrTH4RbU83<=>E;W)1bR`Z8gTP9n)6YW9WHa}qIsBV4IR({e4IN>P=FP|Bd}Nnbu4spSK?vwy`_ym;J8xSemXcXtIQV0OplH zdxA!;#C}D%@UBSSv>Wa-)V~07O`h<)83BHN<&8TI>jW`B-ACdO@}5&kx!wiNG*DHf_+JgW%Fb(`2J z3_rj2G#eJrVG||!LZWRUJv;eY?Za*{uo6i**udK$QJZ_Hp|DPVIvuk5h4QN?0Y3T0 zUrVBxU5%>DJ}#*7e0L4pn>qi|9m>ekii!(qGPeUFmo*2GC946xeN>~}cVFo?W)e)F zYmS{yJ2!UVMa?3W3XI>PnYkG*#dtM+=V5p@+Y`M<@0SYGEUz<c6ZOBDgtjCIr-!;J3mtT8$IMZDz_#i4 zAZzJVs$XHCd`YHbtY!#v!-xI}dkDarphuGnzv_GHVLq~U&m^%FyPQv6tHCd_p!h=->~E0*}J>%)ERE8iaX59mJ5uDq*Z?C;M> zO-ujnM1$COF(!sKM^>?jnw6?Ff>R|Mk?i7wLvC6`)X zlwuG1`n;GP7JN4fBEX&iuh)Bxl_a`w)5c5W-$obJ;`z8TItzE2Q$5ou?I(p%P0(+& zH@v@I+yD)o!!UO2+<@b9!Nnd;dG1){#&q>$j@VhP}ZpUv3~uNNkVQb0$HwE zgYZo~dFCW9zKIeLv7RckTIV=%p=~8Q#SeB_RMIycwZ9@n*j)BMN;~BHX0ti4z0WN6`&sU3Q5P5dDstP^as9^6_b;#wSy^-b z_zyQ}Xg+l2uI7D+KMI|Ry!0?soS)t7+xxMAfflqQv11)E^^Uw`_YgN>82+B0CB=J=^8XQ2tDN-E=nh}PP%2L{;BlwGo97Wj5kBR62E+o zwa(nc{YU17v3IE6 zU3B<}x_B#@Nk(0>J>l)Ok#DyJd!1NePl8 z%IDKt^5Q&`fcuz+WTNRI(ZNWrAh9Zm{`6HfA-jg#<5q_HYum!9*XI3g?U(bB)Xqru z${*sNPp>^zN-3!Il6>ql>-?Bd)A73^0+v{vSqj2sJfk))4h z-C%R@awn@Ar-a8>MM;Yxh58n4J6eS-_pUkv8|5^RYAT6aOxZ&I-cs~@d@v(Bxm`nK zcs|_9Og^}jBqAH_w#(5xu0d;g?((v%-SZrNG~rysS@(;q2ZKYkbIH*U!ea}qV>3(h zlX@MiT`(Ud58;V!!l9W@F1?_n#c~DC&|!&pHFS-2b-O_t-K+Oo2*eQoBZ1)SyK(A4 zIt=q2#txz`2Sd)Hrk9S3e@r>F{;jIMqK^{^38BGeJgnQYVxq*Sq4h1gwEw>LogGlyxs-dy1&(0p-^{D@;vUc|C zr!e(1*M`vP=S~{Srfr34{2{51yRSGs=INDtB>8nJbs?_>Me$-fJMb_De!3FkA6poK|vVo2BrxI7yYUb6dE1fahQd?#4gtEOP*}dgu?H-Yd z>F)evq+0J^7&4%>sUQqZ`LW5}8l68R8E^*ds1DHOy8^<4K20 zyjuHXVX9DMDtKSjs+5horO)#`=#HO#^3sQc^`1T3`mSCr%3GNrwDOy_zOmcpRVV>Xec<~PMu;)$oKtEDM6hadw3|Ss-RzVFkgtZ?Ht`}tg>Tnyf1<=%1$+@h-mCqosa$o#sWpVUopJw8 zd4z`V+BeI3*Pl~ue7Qw3Htvdv2JzR!^k9$Do&YKGWHjZ{JInp$-kn<4J{sAbZS^B0 z@=N98%uc(vwe&DvWfTPd_n8tKsjJ;IN;K*N;%Vqx60?iblGE4Y|)#L0^@y0w2t zBJ!4gqUM7P9hP64;Zr=-zy@V;Z|X*rVO~CAweKlf-4tS%*w+xKuua_TO*w4_P9BJ$ z5WcJp`}})0xx8m~bAR3~ZK7s9j=flZ6|BOmM~hHfj?rS%-e7!U%YSh{gkbf)``8?_hciYD9(oJy%q1T-0WX7Fba75d^skmKWc}6%l$m6c%^d6 zi+^o0PSMA4`T-Ry{%;9i2TX+Krre)rFi5)?D06s>we4M4=Hz6};DVDfCz! z_3mQ;mbhH4?bA;7%d3@Ex3%Pt;E`j_iuAoAHq|<(kfM(losI#nS?7PDkom^)R9JSS z*slM_I4JtgnaEF^6Hs05s!2o^e(c>fqezno>5!5q*WvD7J3=SijPAFY2d8Pv|hYen?RvB z49b7R3zqrPh?5L3usJMOp;DUXauH7oq`Y4Z*_Ozh!X|$tJ z$%uHk&-y)^IzJ(Nmfd#s=W{Z3r16#U@+d(9Oi_j|z6V!Wn(6T%!TdN#8!=0mVgVfLEd1z(`syuakTG@gQ(su7$2`@_ zL6?~@c^4vEx+HlaPZ%B6GyO5H<@dQ(T&xL|fgrn>Z>X z@J4i#G$H!Bh5-FyN2fhVWyL`6=Xq7QeEXd~m;93;B0TkHde!Q~e#+sizkWC7@2S|( zJ&)2@>a&Wt&iG6eOVjs+F`J7|@f2kyZ>f!*kKYy=I2WyTbvYv?hK~?2rNB-<8Q1f* zCROURVFcu#zv^oi+qp+k7Vq@@F14{L%BShqto-1`$!^d1;O=eG7Tod84x4UK$8Rah zWS#rkKQvTC++F48qwJo?YsBabliIrPAj|jCy?G}`eQ`} z3e^d}ZL#0tFt-U!twlr_Qi_7L+k~s9Phb&AIe9;ZLMYWUn&bZQ7^i(aXGI<9Afg18Cl|0$ zYO1ldH(~GK5W>JFd&c1wQ1~97Pt3PLJ!{?vI;`SYN;(7AdueZu{vz+NJ2{efBs@6w zYKUKz;yU!om4+TnsU@pk%eNG0B}?4?cAwE5FNS_EydnFlOUcQ{?6PXNgSCkDOc3y| zFZ$;gyPY$#`os0+zGZdIweEhkPIc3i7RC$ZwF3jqjF^vR{~+ulN7(QDw=~s4=`>Z^ z^|d2zDN|ut7=$7+U!rvE>7PVx70Q?WqKXiU5>)1)RJ1&d;YKo{;XGNc1&E?%Xw>rpAh61Zc-#Z)y-@A^YE4Z)m^V_x8H(arHaF*&V)xW0cYj845n<#Uh~40lT2gM z0%a!>?Pia!$E(KEtU>6L89(_Y_y4CBpv(v;?obZe(asDBgQHdLz$^IQ<3^>p_}Uqx zlDh;;EKT%(7I3n!1?Pw3Y~{@DPrMrpue=%a&^YD#Sx~G!pR)N^E8uWqrNXuc?0co` zil*0N4IPlL;)XLM+bYoI>&1q}h;lSKr@~Emloh}sb6h6!0hQSafO*%R1r#dzU}>h# zH^`1$N$Ru%uQ-qk3iG4`LEQ_r0;17eW@mNS`A50u7l~>U5w}Fkg>o#T+TN}cTBO++ z;Qx`O@r4ZGAquX@g=1;M#l`T)=^tn5hYog|si{9p}Q{#>j-?U0GR8d8I@z$rf z?ZY9r;8qAjF;mCkaCZ9IiVHjRL3mMFxS`L%VX*hVZyI(6$s``!BN)dbXvYR$kS}=d$?a_}tL}6PcHP8YhVghoirK zlRcM<6lob!zh-v~6&At?Ph{U;rFyrkX)kv8^G=Dv*3)Sn2;dFK#dOciXM>g2e;;ivv9_cc46$n?| zuzz~-;cCd4bG6&6dHjR+MQQP-{DM!?{nyEQ_D%fifz%AyRYK|yn|YU0#u0t!@xTtN zxUSV~(agDt;WB#N2%mK}#l*{GA+QUPMX>JO;D0FyO=BR*WW~k(?R%c=3VYWO>fR3Z~sxacl zrJ8Mxs-dh^pR`zEt_X!qHW$-b|={Yqc&At4L#C^;*q2 z`Qc(5X)iV)KplaZR&OA=CSLoJs-yJ~BBLt*Eys4^MAye#BI({7d#mjHn+ALJt6bW; zei!upT%5OPDdC6~wB-yw zec?Kza%I%0fPLZfTJN)ViQv}E{Cs|K%S)VaG}h8#It{S3X* zc3FsEOJ^v1DA62!(nG*Lh~o*3mAVKE)hY4Trp(y91i9AL4zJf5xc;z;`!Y}N|9yvq z=WpAg=za7AO$>?HnU6`yn?_L+EKr|JWUv;={JPT57PImBQ z$w8S00nJO&JB~iFk6Clz8k-^L@(L|MX5-OL z`Ro!&tD>I-Fy~EW5yh2TCeYaeJ=+u?gW(spsXIJtQ$I{8FUPnwy<-`^z$W`GFfoUq z%`wAncwj_bV4HFW1d&di3QmPSh`E9Tf$)LOXFCSajs1tdTiWl1i;9cgfWvkUdupT6 z$n2V^A+&srp@C?<`*m9~PzNghz-g?!JaDy6C;YmGOycEk1Sk;9xvs%S+BX%Zd$YJR z_pTUShHgWSs2#*Gzw|-K8JHT~$_g*F9}J;h>(`otPUc~YWeC^2vl23@g0#6psG46l zHQqO~N?#u)#Ru3u

y6hd<;yI}wde_p!QF|EVvBsr0q5*^NJ$%$wB~Hl)q-Wa4rO zpQx{$CYClaR^GcnvOW>l^r1{F^Wx`cWBm{ZA>#pLr1JE97h2Sl zh)r4-u<`xpHgymKU-&a(`Kx7VgipZy$@ZPr0kHn{0vDf$cRgb_L?N0o_iUH zyBx`N=JodLuJ_yVg~24Y!v^g2(s|C)mmq)Q4|uMmF`ZEcOEldCW~s4L^wh<10h61yVPI%T40vE>#n(j} z&-_j*$Gm0jR3oe9aO3JEmC8pB>FuSoL-*W(^75j)R8VX?t0DsNKDCnAv)`8%?ds!f zesL|DJ8)H@kxQ-5J;9+!54?_Z+x^#Msx?7Uo78*n_Yx<(?1qN!Y_dLVd*wqxOnKgz z6Q=O6T;F*7T-nJ|g(Z)Qaf>@PUEcW;`muuneDTdM3-$E9y#Ddq21=5(O)*15CJu!k z-`ub6cR-lh{hDkl?cHCHt^r#j2nTC?^DLIAlHU`lS=0O$UK1Dg@vT5l*{vIzwHe-* zA5X>gl<5y;1t1?Ujb?q&4-l44?LpSmMw~J^tm1h9%XPq=q_~s2@ux z0u2d6Oh!2C^(uyFCVtAMRy%XO>jay%pTHi^RV{A;dVxtQd8-b^%R)amSegE$hhF`X zs|5)=75)9v)$8Aa&aj^O8GPz&!o5fOUe(p8Tohtens}r=m=V48^qNL4Awb??5VeRA zcMt5%L+sY$YIeiSmtQQj-jRfI)vx?P!)KD+M&e1IR^8O0@E?WE5Du%@Ph`0RIj>p{ zk?~>|5SiWxX|^eLo$FyKj;I>E`m7CjP>f&yye51dXmndtt943EayReAhIlMAa zN1NudgC|t`K7D?+6emEa?qOt1D&F6Zubauk*Ad|r+r*>sWlzIhC>P>|?16?7^fT`= zkyI5ET5>PMrMGDM6HLj=Mr!x1=@KCfbqQ&xDs9xTusnWQt7SKBhMa*Dkv^RzM=wOg z`G)bPX&drvC(UOcv6RtrKFtngErY1b{8ix?Tzd!|AK&7z13$^4mkf--N7(6v8 z`UJG1{BfLyQJMil&G#uCaophJLH>eTqWd~UEeSZ0!;K8><~pU z@MRW^GSBmOOMKLCy$e?VY~xx~ltRv$ItTiYrp)+xdanGD4+AK*M#fl-+--*I>KgL$ zgv*Unrk;J=k-=^G`lsh;B{=9U2RoTdd%eeW>scVuc*?I-Dv|&7daLl^KPnxV*qu9! zxv9PzOU?}|gGdF|nQC+}JD8#G@eJ#3flfqOjkqD&voJd_^N8yd0ls>k9aAs9tF28D z6YryA$y|o^ebk}MD&d%ZPiJH>&C^Uyy3>-s&V#XAACJ8oRGya46u!O97Xo>76O2vZc(^BHbTXL) z&3BkJjP(Y$wzi5INPp6BwN9xy9|0?1BYEQ7O4<2TR>htqtzy8Tr}f*DK5n7$!BX2TokLqo$(w49_m z0Oo(PkU9mZhK4qff3QY{oIG?p`o3WA(#yu_wcEU z`CNN}F{VTM{n4+L>~3ild1o+uL@*m2-~KYH|w* z{Q0NRuZFYc;$6vo3+~mDxLTKX8uD6Tk#9>g_vNFes~$3(9e^fp?=jW82v|Jh$E=3p zn3t%V?JLU|vj23OVFNf{OxKPgcCkI#&D#(AcuI{kaK& z_dpMVoAjNl7vcnYcu*+&77AWfDH`(Ls>rPaS~yhSm4~`{;|99rcfd}ossHaJQ-x79 zyrRg#@H0@)u47<40L*wg?6J9F>a#sE+^|kYS*ypZKzU7DW0Kv(@5sp1fvY|Rd3g?w z9weiu+(0d6q8MZM@L6d{R?LRO|#XiXuz+@^c^98=@O*{ z16oGDT&CuKuNLQ$Bm)F^c=+j;8KD#Yp%WDczi~b58J#JaJRZzJmtxh3&FYprJ z35Qampg9bh+Z`y?moI-h*u22F<^U16&w@8Lf`vI%5DX|w6Opo11qBPhA3;D@g}0DO z*{rjD88Lk-(Diy%g~fmVwm_L1NykX00-Rgft}b<^Y%=o;#1@Y1&k~i0KUxo&>b!QpVYgp&I9peVXJK0Eq z%&Rk&bJa!M$+`wI4Gg2-B*gf565AD4#JIn%W(oxZYEnT4Kw94e?E)GKbL=FtW1dCY z(<5X4JulWbB`!NCY|V<8xJ-|XJf|o#!kC3J;;&*Ss2Weuy>I4#lvwOthq9WG5_*e>oq>KH6C1PY zS#XRtyJAI(W^RpLe@l*J8gZ_{?+-P%XhbNg$brJB#s`%3(@bHuW1=hPg^Z>lU$EXj8(Q#E$>hV$Rze~X1O!$1$ zJW(^ZKjG_$-!uky5Z4g65~Uy*(rmuyc~6tlO7U3YN8eC4m6gT=h(rB^>LT^9fC$aA zy7y8v>lfxet}B=x_6KfbiV@v`fPYSUdWyDSf{hu+3Lp=@0}d)vMV05bu_bE6YH(88$~I6GXZ(d7h6&PLQSZ^6_(36H{f>Uo zb4auKo!f?=FHpSUWFmI=@UJ-JbIpX4%QMhU$cSQYE0dTY*5D}kT|>0}V02VF|gow{l3wJf_nMm-g-$p|lAOVW8~}xE9DXh~%J8#vjECu>~4{ z8|*B&C39mApvZ#}%VAQtf~iXf?NFNc!!mf%mt(#LeBX~_sQ^7r+X~}VA z+jsdzly9GOlB9E$S;27rn6m7%9_jWXelFLG-JF>jxW%AAFb639 z5j9}~%QK>-*b=NQ#VYVR*x20gs+sU!CCZPGu!gw(B1X;UUYaoI<@NKiiPc3Lpn2RK zZqGI?qxFTr>N*tasb1_c=Z=E`(Jufy`6VSKp`otb<>c*fKJ~Uhab&0tj2-g9iTN?q zdSuxXg>g=E?76p@A#$)}D@-!zDvS_;^6>+E_+K1jmK~!5yxFI9CdFZ)JCY`w2m1A^ zN8IsR7F~a3te&I?X!1toPii7?&W9w97BDDEKe5 z%X2{f-i4!3OD8`AdhfLjT8PHfRE?ZK$Ns$oo{7{!asF23XveK*P7HNQKQqkX`F#Cf66w>_f zs>i*Q(9n&Xio>R8?0%7gKjsmCA^C5PT~d?TCvY4_bpEpEJs;rhku143f0^#h@RQb8 zT{Ww4+)5P~yA+Cl^F9rD6+z0mF_#eAH=#V8HRPGP-wG}Zxj1kgKmakmy&chB2Dh_V zIS~0!5(L%LLlzqH(?>nWbu>4h$$Y44PL&V1j5ID~Hk1wzCidD=i|8Xbrb+xFcSEWH&u+sXgog_BAbB ziUPtyU*@Aa0rDl%_P^f=m@ZAL+T(>lfGeC_`dR@1g@F>j1Z4s99})TseCcT#|MJmg$+>*uvE#Alp3rpg18A?#_nVn_v zzcuA#SW3}fMM~&3DW4C=u+qPW@*vedt7CFZidi6Hp`r7;v@WCkB2gFEHKi9k8jf=M z754K4Q>*Q_J58ULyUtdJQc&(T?Ej(+2Gyv*bjx_HJZ5AxB+c3>>}01M4IsH|#CTkx zNIeR#IgW$G$jX1;A8GV2Eu42%3P-~?Mn*^Cs*V5eU zH?dm#uUz+coN(b78t&SoDGUU|OIflS-%y-ao{ej0cIb}j+w4##jXRGmEiI2iD>04k z{Vj8Q(VJKn;BLuVi?TY-Z~L-i4|cp1tS|!uimsixBH;QRQag8`<6R9#uv9r>zU%GY=~ZZi-t!zV}7i(Fb*pl>ubZ5Fz9YG z(U&?fy)L+n#3h6?GA1$E&11r2IB@lER6vV}X~5{av=>#b%1a|9r6$$x9e*xc0jBR> zpcsbdq4D)g%>0hHl4&*%JpMg&pUE8wL>Wq}8sCZS$vI)aai{E#c~0tjCub&OTcb6%!rnx~UjHMvz{3}ks!D@p zaNSuWol6vr_dgEeSJ=mNI$sqG6R_U>-6ejl8(MW6%nf_5`DIB<=TnK(z2TfPo>`G7Yaem@xMF`C0O(Ppy2;_s044W>qkvM! z(Y_4I03(HuLm$_~&~LtVCbq<#Wl3YNWiK4D$E#ua%`j=Mn53`N($)1iw}|kd0uxKg zY-k_*fvRe-yqieJ{ho?q|Mn!@Mo3h{{5!lKKH|NsltsYyZ=m7#VW0<95sGc(2;f69 zL6HNi44E+!i`7`P2Vq7gm8{!KBy1KPjJH)6S%SrB01elKzBmFR{eFhx)K0Dmi0Rj7 zqfmWd1WTz2P(q~4%0Uhsi|9KVN+U1)79PKiSygo|dezaKV@E*QD}jLOyVP-0?4XL0 z;jl-F8?3bSXr#=}g1C@N{-u{vO{$3sm^o~?w-7*hkC3k#Ud}-nl?;@DI8P^AOXt4)%cKA z#of~Cz_&4j9H)ombYWp=l&kKrM=r|MACnU{Qe`>3Y&l&gax|8263{w>&SOy6T+kZb zdxIxiDJh9#R^v@7FySDMFuQx~(VHiRzLZJP1m~Y&>-`ZPz}h7yEX&A~3B^8x_#W?H z!Nrb=bDfk}cGn1lEJf_DpoiHV=87r&YYe4iEE}83A=)Su42nd-F^gyo#u7^mT10kp zw0CcS9k*tfMqKe2Hr?!tubrQBssN|m0%%mkus+k{0x8JjxtyZdFEcVsDLeF+H2y09 z|D8K`$hCg^8y|`fx0KT`Rc?3}jkVcXmu)YSjB?^O%jJhValr;6pQ3V9^Ud8jc9NfeRg4eur@Y(@rw8F+|+D^bsk& zQ@UJF?*gJO)e4xA&@&VuWo2|VDECxlGwDIyCVrcVh9)E=s7b5ZVMri@cX~o|_dLbE z?3ZyPHfHgGYrXaIRd7_ED_7C;5?LOeNNF4RT@6F!m@hg`H4{BgT7bmIV<`)4Yv$5+ zeRd2?PY(p^U4<_nL6KjR$1iL2gYeHrlyymJrP4B&dn=vRuhcG4L{rO|JcVQ-H#4_$ zdM%~YuP^Ee?)rTlLo30wQ8y)d01PMZQ&gWrqtnD00zt;;ELJ<^qZTOvWp=Xe7BRST zdq_iDPZ|cT(Sb1rH<=MjQ}@f@K%e(@U<>SqPPl_X7IGAe(b1;D?vxDqQAehfZkb;s{V<(DB~2t_)?MI1k6`JqWi9`74Y^76!UghAYN361bh6!m2Eg z*)bNLo)U$ofm1^whXnqHQV^#A9lZq6g@`e_<(s3Gl@xdmr16C^Ud!R_vIme7*Vgc( zqht=qc2}2U9?A%&6n2Sk_9 /dev/null; then + echo "Installing dependencies..." + .virtualenv/bin/python ./.virtualenv/bin/pip install --index-url https://pypi.python.org/simple/ -r ./requirements.txt + cp ./requirements.txt ./.virtualenv/ +fi diff --git a/modules/api/functional_test/boto_request_signer.py b/modules/api/functional_test/boto_request_signer.py new file mode 100644 index 0000000000..b25326a09e --- /dev/null +++ b/modules/api/functional_test/boto_request_signer.py @@ -0,0 +1,81 @@ +import logging + +from datetime import datetime +from hashlib import sha256 + +from boto.dynamodb2.layer1 import DynamoDBConnection + +import requests.compat as urlparse + +logger = logging.getLogger(__name__) + +__all__ = [u'BotoRequestSigner'] + + +class BotoRequestSigner(object): + + def __init__(self, index_url, access_key, secret_access_key): + url = urlparse.urlparse(index_url) + self.boto_connection = DynamoDBConnection( + host = url.hostname, + port = url.port, + aws_access_key_id = access_key, + aws_secret_access_key = secret_access_key, + is_secure = False) + + @staticmethod + def canonical_date(headers): + """Derive canonical date (ISO 8601 string) from headers if possible, + or synthesize it if no usable header exists.""" + iso_format = u'%Y%m%dT%H%M%SZ' + http_format = u'%a, %d %b %Y %H:%M:%S GMT' + + def try_parse(date_string, format): + if date_string is None: + return None + try: + return datetime.strptime(date_string, format) + except ValueError: + return None + + amz_date = try_parse(headers.get(u'X-Amz-Date'), iso_format) + http_date = try_parse(headers.get(u'Date'), http_format) + fallback_date = datetime.utcnow() + + date = next(d for d in [amz_date, http_date, fallback_date] if d is not None) + return date.strftime(iso_format) + + def build_auth_header(self, method, path, headers, body, params=None): + """Construct an Authorization header, using boto.""" + + request = self.boto_connection.build_base_http_request( + method=method, + path=path, + auth_path=path, + headers=headers, + data=body, + params=params or {}) + + auth_handler = self.boto_connection._auth_handler + + timestamp = BotoRequestSigner.canonical_date(headers) + request.timestamp = timestamp[0:8] + + request.region_name = u'us-east-1' + request.service_name = u'VinylDNS' + + credential_scope = u'/'.join([request.timestamp, request.region_name, request.service_name, u'aws4_request']) + + canonical_request = auth_handler.canonical_request(request) + hashed_request = sha256(canonical_request.encode(u'utf-8')).hexdigest() + + string_to_sign = u'\n'.join([u'AWS4-HMAC-SHA256', timestamp, credential_scope, hashed_request]) + signature = auth_handler.signature(request, string_to_sign) + headers_to_sign = auth_handler.headers_to_sign(request) + + auth_header = u','.join([ + u'AWS4-HMAC-SHA256 Credential=%s' % auth_handler.scope(request), + u'SignedHeaders=%s' % auth_handler.signed_headers(headers_to_sign), + u'Signature=%s' % signature]) + + return auth_header diff --git a/modules/api/functional_test/conftest.py b/modules/api/functional_test/conftest.py new file mode 100644 index 0000000000..c75f0eb534 --- /dev/null +++ b/modules/api/functional_test/conftest.py @@ -0,0 +1,77 @@ +import os +import pytest +import boto.dynamodb2 +from boto.dynamodb2.table import Table +from boto.dynamodb2.fields import HashKey +from boto.dynamodb2.fields import GlobalAllIndex + +from vinyldns_context import VinylDNSTestContext + +def pytest_addoption(parser): + """ + Adds additional options that we can parse when we run the tests, stores them in the parser / py.test context + """ + parser.addoption("--url", dest="url", action="store", default="http://localhost:9000", + help="URL for application to root") + parser.addoption("--dns-ip", dest="dns_ip", action="store", default="127.0.0.1:19001", + help="The ip address for the dns server to use for the tests") + parser.addoption("--dns-zone", dest="dns_zone", action="store", default="vinyldns.", + help="The zone name that will be used for testing") + parser.addoption("--dns-key-name", dest="dns_key_name", action="store", default="vinyldns.", + help="The name of the key used to sign updates for the zone") + parser.addoption("--dns-key", dest="dns_key", action="store", default="nzisn+4G2ldMn0q1CV3vsg==", + help="The tsig key") + + # optional + parser.addoption("--basic-auth", dest="basic_auth_creds", + help="Basic auth credentials in 'user:pass' format") + parser.addoption("--basic-auth-realm", dest="basic_auth_realm", + help="Basic auth realm to use with credentials supplied by \"-b\"") + parser.addoption("--iauth-creds", dest="iauth_creds", + help="Intermediary auth (codebig style) in 'key:secret' format") + parser.addoption("--oauth-creds", dest="oauth_creds", + help="OAuth credentials in consumer:secret format") + parser.addoption("--environment", dest="cim_env", action="store", default="test", + help="CIM_ENV that we are testing against.") + parser.addoption("--log-level", dest="logging_level", + help="logging level should be CRITICAL, ERROR, WARNING, INFO or DEBUG") + + +def pytest_configure(config): + """ + Loads the test context since we are no longer using run.py + """ + + # Monkey patch ssl so we do not verify ssl certs + import ssl + try: + _create_unverified_https_context = ssl._create_unverified_context + except AttributeError: + # Legacy Python that doesn't verify HTTPS certificates by default + pass + else: + # Handle target environment that doesn't support HTTPS verification + ssl._create_default_https_context = _create_unverified_https_context + + url = config.getoption("url", default="http://localhost:9000/") + if not url.endswith('/'): + url += '/' + + import sys + sys.dont_write_bytecode = True + + VinylDNSTestContext.configure(config.getoption("dns_ip"), + config.getoption("dns_zone"), + config.getoption("dns_key_name"), + config.getoption("dns_key"), + config.getoption("url")) + +def pytest_report_header(config): + """ + Overrides the test result header like we do in pyfunc test + """ + header = "Testing against CIM_ENV " + config.getoption("cim_env") + header += "\r\nURL: " + config.getoption("url") + header += "\r\nRunning from directory " + os.getcwd() + header += '\r\nTest shim directory ' + os.path.dirname(__file__) + return header diff --git a/modules/api/functional_test/live_tests/batch/create_batch_change_test.py b/modules/api/functional_test/live_tests/batch/create_batch_change_test.py new file mode 100644 index 0000000000..e83efee011 --- /dev/null +++ b/modules/api/functional_test/live_tests/batch/create_batch_change_test.py @@ -0,0 +1,2306 @@ +from hamcrest import * +from utils import * + +def does_not_contain(x): + is_not(contains(x)) + +def validate_change_error_response_basics(input_json, change_type, input_name, record_type, ttl, record_data): + assert_that(input_json['changeType'], is_(change_type)) + assert_that(input_json['inputName'], is_(input_name)) + assert_that(input_json['type'], is_(record_type)) + assert_that(record_type, is_in(['A', 'AAAA', 'CNAME', 'PTR', 'TXT', 'MX'])) + if change_type=="Add": + assert_that(input_json['ttl'], is_(ttl)) + if record_type in ["A", "AAAA"]: + assert_that(input_json['record']['address'], is_(record_data)) + elif record_type=="CNAME": + assert_that(input_json['record']['cname'], is_(record_data)) + elif record_type=="PTR": + assert_that(input_json['record']['ptrdname'], is_(record_data)) + elif record_type=="TXT": + assert_that(input_json['record']['text'], is_(record_data)) + elif record_type=="MX": + assert_that(input_json['record']['preference'], is_(record_data['preference'])) + assert_that(input_json['record']['exchange'], is_(record_data['exchange'])) + return + +def assert_failed_change_in_error_response(input_json, change_type="Add", input_name="fqdn.", record_type="A", ttl=200, record_data="1.1.1.1", error_messages=[]): + validate_change_error_response_basics(input_json, change_type, input_name, record_type, ttl, record_data) + assert_error(input_json, error_messages) + return + +def assert_successful_change_in_error_response(input_json, change_type="Add", input_name="fqdn.", record_type="A", ttl=200, record_data="1.1.1.1"): + validate_change_error_response_basics(input_json, change_type, input_name, record_type, ttl, record_data) + assert_that('errors' in input_json, is_(False)) + return + +def assert_change_success_response_values(changes_json, zone, index, record_name, input_name, record_data, ttl=200, record_type="A", change_type="Add"): + assert_that(changes_json[index]['zoneId'], is_(zone['id'])) + assert_that(changes_json[index]['zoneName'], is_(zone['name'])) + assert_that(changes_json[index]['recordName'], is_(record_name)) + assert_that(changes_json[index]['inputName'], is_(input_name)) + if change_type=="Add": + assert_that(changes_json[index]['ttl'], is_(ttl)) + assert_that(changes_json[index]['type'], is_(record_type)) + assert_that(changes_json[index]['id'], is_not(none())) + assert_that(changes_json[index]['changeType'], is_(change_type)) + assert_that(record_type, is_in(['A', 'AAAA', 'CNAME', 'PTR', 'TXT', 'MX'])) + if record_type in ["A", "AAAA"] and change_type=="Add": + assert_that(changes_json[index]['record']['address'], is_(record_data)) + elif record_type=="CNAME" and change_type=="Add": + assert_that(changes_json[index]['record']['cname'], is_(record_data)) + elif record_type=="PTR" and change_type=="Add": + assert_that(changes_json[index]['record']['ptrdname'], is_(record_data)) + elif record_type=="TXT" and change_type=="Add": + assert_that(changes_json[index]['record']['text'], is_(record_data)) + elif record_type=="MX" and change_type=="Add": + assert_that(changes_json[index]['record']['preference'], is_(record_data['preference'])) + assert_that(changes_json[index]['record']['exchange'], is_(record_data['exchange'])) + return + +def assert_error(input_json, error_messages): + for error in error_messages: + assert_that(input_json['errors'], has_item(error)) + assert_that(len(input_json['errors']), is_(len(error_messages))) + + +def test_create_batch_change_with_adds_success(shared_zone_test_context): + """ + Test successfully creating a batch change with adds + """ + client = shared_zone_test_context.ok_vinyldns_client + parent_zone = shared_zone_test_context.parent_zone + ok_zone = shared_zone_test_context.ok_zone + classless_delegation_zone = shared_zone_test_context.classless_zone_delegation_zone + classless_base_zone = shared_zone_test_context.classless_base_zone + ip6_reverse_zone = shared_zone_test_context.ip6_reverse_zone + + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("parent.com.", address="4.5.6.7"), + get_change_A_AAAA_json("parent.com", address="4.5.6.7"), + get_change_A_AAAA_json("ok.", record_type="AAAA", address="fd69:27cc:fe91::60"), + get_change_A_AAAA_json("relative.parent.com.", address="1.1.1.1"), + get_change_A_AAAA_json("relative.parent.com", address="2.2.2.2"), + get_change_CNAME_json("cname.parent.com", cname="nice.parent.com"), + get_change_CNAME_json("2cname.parent.com", cname="nice.parent.com"), + get_change_CNAME_json("4.2.0.192.in-addr.arpa.", cname="4.4/30.2.0.192.in-addr.arpa."), + get_change_PTR_json("192.0.2.193", ptrdname="www.vinyldns"), + get_change_PTR_json("192.0.2.44"), + get_change_PTR_json("fd69:27cc:fe91::60", ptrdname="www.vinyldns"), + get_change_TXT_json("txt.ok."), + get_change_TXT_json("ok."), + get_change_TXT_json("txt-unique-characters.ok.", text='a\\\\`=` =\\"Cat\\"\nattr=val'), + get_change_TXT_json("txt.2.0.192.in-addr.arpa."), + get_change_MX_json("mx.ok.", preference=0), + get_change_MX_json("mx.ok.", preference=65535), + get_change_MX_json("ok.", preference=1000, exchange="bar.foo.") + ] + } + + to_delete = [] + try: + result = client.create_batch_change(batch_change_input, status=202) + completed_batch = client.wait_until_batch_change_completed(result) + record_set_list = [(change['zoneId'], change['recordSetId']) for change in completed_batch['changes']] + to_delete = set(record_set_list) # set here because multiple items in the batch combine to one RS + + ## validate initial response + assert_that(result['comments'], is_("this is optional")) + assert_that(result['userName'], is_("ok")) + assert_that(result['userId'], is_("ok")) + assert_that(result['id'], is_not(none())) + assert_that(completed_batch['status'], is_("Complete")) + + assert_change_success_response_values(result['changes'], zone=parent_zone, index=0, record_name="parent.com.", + input_name="parent.com.", record_data="4.5.6.7") + assert_change_success_response_values(result['changes'], zone=parent_zone, index=1, record_name="parent.com.", + input_name="parent.com.", record_data="4.5.6.7") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=2, record_name="ok.", + input_name="ok.", record_data="fd69:27cc:fe91::60", record_type="AAAA") + assert_change_success_response_values(result['changes'], zone=parent_zone, index=3, record_name="relative", + input_name="relative.parent.com.", record_data="1.1.1.1") + assert_change_success_response_values(result['changes'], zone=parent_zone, index=4, record_name="relative", + input_name="relative.parent.com.", record_data="2.2.2.2"), + assert_change_success_response_values(result['changes'], zone=parent_zone, index=5, record_name="cname", + input_name="cname.parent.com.", record_data="nice.parent.com.", record_type="CNAME") + assert_change_success_response_values(result['changes'], zone=parent_zone, index=6, record_name="2cname", + input_name="2cname.parent.com.", record_data="nice.parent.com.", record_type="CNAME") + assert_change_success_response_values(result['changes'], zone=classless_base_zone, index=7, record_name="4", + input_name="4.2.0.192.in-addr.arpa.", record_data="4.4/30.2.0.192.in-addr.arpa.", record_type="CNAME") + assert_change_success_response_values(result['changes'], zone=classless_delegation_zone, index=8, record_name="193", + input_name="192.0.2.193", record_data="www.vinyldns.", record_type="PTR") + assert_change_success_response_values(result['changes'], zone=classless_base_zone, index=9, record_name="44", + input_name="192.0.2.44", record_data="test.com.", record_type="PTR") + assert_change_success_response_values(result['changes'], zone=ip6_reverse_zone, index=10, record_name="0.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", + input_name="fd69:27cc:fe91::60", record_data="www.vinyldns.", record_type="PTR") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=11, record_name="txt", + input_name="txt.ok.", record_data="test", record_type="TXT") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=12, record_name="ok.", + input_name="ok.", record_data="test", record_type="TXT") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=13, record_name="txt-unique-characters", + input_name="txt-unique-characters.ok.", record_data='a\\\\`=` =\\"Cat\\"\nattr=val', record_type="TXT") + assert_change_success_response_values(result['changes'], zone=classless_base_zone, index=14, record_name="txt", + input_name="txt.2.0.192.in-addr.arpa.", record_data="test", record_type="TXT") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=15, record_name="mx", + input_name="mx.ok.", record_data={'preference': 0, 'exchange': 'foo.bar.'}, record_type="MX") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=16, record_name="mx", + input_name="mx.ok.", record_data={'preference': 65535, 'exchange': 'foo.bar.'}, record_type="MX") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=17, record_name="ok.", + input_name="ok.", record_data={'preference': 1000, 'exchange': 'bar.foo.'}, record_type="MX") + + completed_status = [change['status'] == 'Complete' for change in completed_batch['changes']] + assert_that(all(completed_status), is_(True)) + + ## get all the recordsets created by this batch, validate + rs1 = client.get_recordset(record_set_list[0][0], record_set_list[0][1])['recordSet'] + expected1 = {'name': 'parent.com.', + 'zoneId': parent_zone['id'], + 'type': 'A', + 'ttl': 200, + 'records': [{'address': '4.5.6.7'}]} + verify_recordset(rs1, expected1) + + rs2 = client.get_recordset(record_set_list[1][0], record_set_list[1][1])['recordSet'] + assert_that(rs2, is_(rs1)) # duplicate entry, should get same thing + + rs3 = client.get_recordset(record_set_list[2][0], record_set_list[2][1])['recordSet'] + expected3 = {'name': 'ok.', + 'zoneId': ok_zone['id'], + 'type': 'AAAA', + 'ttl': 200, + 'records': [{'address': 'fd69:27cc:fe91::60'}]} + verify_recordset(rs3, expected3) + + rs4 = client.get_recordset(record_set_list[3][0], record_set_list[3][1])['recordSet'] + expected4 = {'name': 'relative', + 'zoneId': parent_zone['id'], + 'type': 'A', + 'ttl': 200, + 'records': [{'address': '1.1.1.1'}, {'address': '2.2.2.2'}]} + verify_recordset(rs4, expected4) + + rs5 = client.get_recordset(record_set_list[5][0], record_set_list[5][1])['recordSet'] + expected5 = {'name': 'cname', + 'zoneId': parent_zone['id'], + 'type': 'CNAME', + 'ttl': 200, + 'records': [{'cname': 'nice.parent.com.'}]} + verify_recordset(rs5, expected5) + + rs6 = client.get_recordset(record_set_list[6][0], record_set_list[6][1])['recordSet'] + expected6 = {'name': '2cname', + 'zoneId': parent_zone['id'], + 'type': 'CNAME', + 'ttl': 200, + 'records': [{'cname': 'nice.parent.com.'}]} + verify_recordset(rs6, expected6) + + rs7 = client.get_recordset(record_set_list[7][0], record_set_list[7][1])['recordSet'] + expected7 = {'name': '4', + 'zoneId': classless_base_zone['id'], + 'type': 'CNAME', + 'ttl': 200, + 'records': [{'cname': '4.4/30.2.0.192.in-addr.arpa.'}]} + verify_recordset(rs7, expected7) + + rs8 = client.get_recordset(record_set_list[8][0], record_set_list[8][1])['recordSet'] + expected8 = {'name': '193', + 'zoneId': classless_delegation_zone['id'], + 'type': 'PTR', + 'ttl': 200, + 'records': [{'ptrdname': 'www.vinyldns.'}]} + verify_recordset(rs8, expected8) + + rs9 = client.get_recordset(record_set_list[9][0], record_set_list[9][1])['recordSet'] + expected9 = {'name': '44', + 'zoneId': classless_base_zone['id'], + 'type': 'PTR', + 'ttl': 200, + 'records': [{'ptrdname': 'test.com.'}]} + verify_recordset(rs9, expected9) + + rs10 = client.get_recordset(record_set_list[10][0], record_set_list[10][1])['recordSet'] + expected10 = {'name': '0.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', + 'zoneId': ip6_reverse_zone['id'], + 'type': 'PTR', + 'ttl': 200, + 'records': [{'ptrdname': 'www.vinyldns.'}]} + verify_recordset(rs10, expected10) + + rs11 = client.get_recordset(record_set_list[11][0], record_set_list[11][1])['recordSet'] + expected11 = {'name': 'txt', + 'zoneId': ok_zone['id'], + 'type': 'TXT', + 'ttl': 200, + 'records': [{'text': 'test'}]} + verify_recordset(rs11, expected11) + + rs12 = client.get_recordset(record_set_list[12][0], record_set_list[12][1])['recordSet'] + expected12 = {'name': 'ok.', + 'zoneId': ok_zone['id'], + 'type': 'TXT', + 'ttl': 200, + 'records': [{'text': 'test'}]} + verify_recordset(rs12, expected12) + + rs13 = client.get_recordset(record_set_list[13][0], record_set_list[13][1])['recordSet'] + expected13 = {'name': 'txt-unique-characters', + 'zoneId': ok_zone['id'], + 'type': 'TXT', + 'ttl': 200, + 'records': [{'text': 'a\\\\`=` =\\"Cat\\"\nattr=val'}]} + verify_recordset(rs13, expected13) + + rs14 = client.get_recordset(record_set_list[14][0], record_set_list[14][1])['recordSet'] + expected14 = {'name': 'txt', + 'zoneId': classless_base_zone['id'], + 'type': 'TXT', + 'ttl': 200, + 'records': [{'text': 'test'}]} + verify_recordset(rs14, expected14) + + rs15 = client.get_recordset(record_set_list[15][0], record_set_list[15][1])['recordSet'] + expected15 = {'name': 'mx', + 'zoneId': ok_zone['id'], + 'type': 'MX', + 'ttl': 200, + 'records': [{'preference': 0, 'exchange': 'foo.bar.'}, {'preference': 65535, 'exchange': 'foo.bar.'}]} + verify_recordset(rs15, expected15) + + rs16 = client.get_recordset(record_set_list[17][0], record_set_list[17][1])['recordSet'] + expected16 = {'name': 'ok.', + 'zoneId': ok_zone['id'], + 'type': 'MX', + 'ttl': 200, + 'records': [{'preference': 1000, 'exchange': 'bar.foo.'}]} + verify_recordset(rs16, expected16) + + finally: + clear_zoneid_rsid_tuple_list(to_delete, client) + + +def test_create_batch_change_with_updates_deletes_success(shared_zone_test_context): + """ + Test successfully creating a batch change with updates and deletes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + dummy_zone = shared_zone_test_context.dummy_zone + ok_zone = shared_zone_test_context.ok_zone + classless_zone_delegation_zone = shared_zone_test_context.classless_zone_delegation_zone + + ok_zone_acl = generate_acl_rule('Delete', groupId=shared_zone_test_context.dummy_group['id'], recordMask='.*', recordTypes=['CNAME']) + classless_zone_delegation_zone_acl = generate_acl_rule('Write', groupId=shared_zone_test_context.dummy_group['id'], recordTypes=['PTR']) + + rs_delete_dummy = get_recordset_json(dummy_zone, "delete", "AAAA", [{"address": "1:2:3:4:5:6:7:8"}]) + rs_update_dummy = get_recordset_json(dummy_zone, "update", "A", [{"address": "1.2.3.4"}]) + rs_delete_ok = get_recordset_json(ok_zone, "delete", "CNAME", [{"cname": "delete.cname."}]) + rs_update_classless = get_recordset_json(classless_zone_delegation_zone, "193", "PTR", [{"ptrdname": "will.change."}]) + txt_delete_dummy = get_recordset_json(dummy_zone, "delete-txt", "TXT", [{"text": "test"}]) + mx_delete_dummy = get_recordset_json(dummy_zone, "delete-mx", "MX", [{"preference": 1, "exchange": "foo.bar."}]) + mx_update_dummy = get_recordset_json(dummy_zone, "update-mx", "MX", [{"preference": 1, "exchange": "foo.bar."}]) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("delete.dummy.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update.dummy.", ttl=300, address="1.2.3.4"), + get_change_A_AAAA_json("Update.dummy.", change_type="DeleteRecordSet"), + get_change_CNAME_json("delete.ok.", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.193", ttl=300, ptrdname="has.changed."), + get_change_PTR_json("192.0.2.193", change_type="DeleteRecordSet"), + get_change_TXT_json("delete-txt.dummy.", change_type="DeleteRecordSet"), + get_change_MX_json("delete-mx.dummy.", change_type="DeleteRecordSet"), + get_change_MX_json("update-mx.dummy.", change_type="DeleteRecordSet"), + get_change_MX_json("update-mx.dummy.", preference=1000) + ] + } + + to_create = [rs_delete_dummy, rs_update_dummy, rs_delete_ok, rs_update_classless, txt_delete_dummy, mx_delete_dummy, mx_update_dummy] + to_delete = [] + + try: + for rs in to_create: + if rs['zoneId'] == dummy_zone['id']: + create_client = dummy_client + else: + create_client = ok_client + + create_rs = create_client.create_recordset(rs, status=202) + create_client.wait_until_recordset_change_status(create_rs, 'Complete') + + # Configure ACL rules + add_ok_acl_rules(shared_zone_test_context, [ok_zone_acl]) + add_classless_acl_rules(shared_zone_test_context, [classless_zone_delegation_zone_acl]) + + result = dummy_client.create_batch_change(batch_change_input, status=202) + completed_batch = dummy_client.wait_until_batch_change_completed(result) + + record_set_list = [(change['zoneId'], change['recordSetId']) for change in completed_batch['changes']] + + to_delete = set(record_set_list) # set here because multiple items in the batch combine to one RS + + ## validate initial response + assert_that(result['comments'], is_("this is optional")) + assert_that(result['userName'], is_("dummy")) + assert_that(result['userId'], is_("dummy")) + assert_that(result['id'], is_not(none())) + assert_that(completed_batch['status'], is_("Complete")) + + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=0, record_name="delete", + input_name="delete.dummy.", record_data=None, record_type="AAAA", change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=1, record_name="update", ttl=300, + input_name="update.dummy.", record_data="1.2.3.4") + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=2, record_name="Update", + input_name="Update.dummy.", record_data=None, change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=ok_zone, index=3, record_name="delete", + input_name="delete.ok.", record_data=None, record_type="CNAME", change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=classless_zone_delegation_zone, index=4, record_name="193", ttl=300, + input_name="192.0.2.193", record_data="has.changed.", record_type="PTR") + assert_change_success_response_values(result['changes'], zone=classless_zone_delegation_zone, index=5, record_name="193", + input_name="192.0.2.193", record_data=None, record_type="PTR", change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=6, record_name="delete-txt", + input_name="delete-txt.dummy.", record_data=None, record_type="TXT", change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=7, record_name="delete-mx", + input_name="delete-mx.dummy.", record_data=None, record_type="MX", change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=8, record_name="update-mx", + input_name="update-mx.dummy.", record_data=None, record_type="MX", change_type="DeleteRecordSet") + assert_change_success_response_values(result['changes'], zone=dummy_zone, index=9, record_name="update-mx", + input_name="update-mx.dummy.", record_data={'preference': 1000, 'exchange': 'foo.bar.'}, record_type="MX") + + rs1 = dummy_client.get_recordset(record_set_list[0][0], record_set_list[0][1], status=404) + assert_that(rs1, is_("RecordSet with id " + record_set_list[0][1] + " does not exist in zone dummy.")) + + rs2 = dummy_client.get_recordset(record_set_list[1][0], record_set_list[1][1])['recordSet'] + expected2 = {'name': 'update', + 'zoneId': dummy_zone['id'], + 'type': 'A', + 'ttl': 300, + 'records': [{'address': '1.2.3.4'}]} + verify_recordset(rs2, expected2) + + # since this is an update, record_set_list[1] and record_set_list[2] are the same record + rs3 = dummy_client.get_recordset(record_set_list[2][0], record_set_list[2][1])['recordSet'] + verify_recordset(rs3, expected2) + + rs4 = dummy_client.get_recordset(record_set_list[3][0], record_set_list[3][1], status=404) + assert_that(rs4, is_("RecordSet with id " + record_set_list[3][1] + " does not exist in zone ok.")) + + rs5 = dummy_client.get_recordset(record_set_list[4][0], record_set_list[4][1])['recordSet'] + expected5 = {'name': '193', + 'zoneId': classless_zone_delegation_zone['id'], + 'type': 'PTR', + 'ttl': 300, + 'records': [{'ptrdname': 'has.changed.'}]} + verify_recordset(rs5, expected5) + + # since this is an update, record_set_list[5] and record_set_list[4] are the same record + rs6 = dummy_client.get_recordset(record_set_list[5][0], record_set_list[5][1])['recordSet'] + verify_recordset(rs6, expected5) + + rs7 = dummy_client.get_recordset(record_set_list[6][0], record_set_list[6][1], status=404) + assert_that(rs7, is_("RecordSet with id " + record_set_list[6][1] + " does not exist in zone dummy.")) + + rs8 = dummy_client.get_recordset(record_set_list[7][0], record_set_list[7][1], status=404) + assert_that(rs8, is_("RecordSet with id " + record_set_list[7][1] + " does not exist in zone dummy.")) + + rs9 = dummy_client.get_recordset(record_set_list[8][0], record_set_list[8][1])['recordSet'] + expected9 = {'name': 'update-mx', + 'zoneId': dummy_zone['id'], + 'type': 'MX', + 'ttl': 200, + 'records': [{'preference': 1000, 'exchange': 'foo.bar.'}]} + verify_recordset(rs9, expected9) + + finally: + # Clean up updates + dummy_deletes = [rs for rs in to_delete if rs[0] == dummy_zone['id']] + ok_deletes = [rs for rs in to_delete if rs[0] != dummy_zone['id']] + clear_zoneid_rsid_tuple_list(dummy_deletes, dummy_client) + clear_zoneid_rsid_tuple_list(ok_deletes, ok_client) + + # Clean up ACL rules + clear_ok_acl_rules(shared_zone_test_context) + clear_classless_acl_rules(shared_zone_test_context) + + +def test_create_batch_change_without_comments_succeeds(shared_zone_test_context): + """ + Test successfully creating a batch change without comments + Test successfully creating a batch using inputName without a trailing dot, and that the + returned inputName is dotted + """ + client = shared_zone_test_context.ok_vinyldns_client + parent_zone = shared_zone_test_context.parent_zone + batch_change_input = { + "changes": [ + get_change_A_AAAA_json("parent.com", address="4.5.6.7"), + ] + } + to_delete = [] + + try: + result = client.create_batch_change(batch_change_input, status=202) + completed_batch = client.wait_until_batch_change_completed(result) + to_delete = [(change['zoneId'], change['recordSetId']) for change in completed_batch['changes']] + + assert_change_success_response_values(result['changes'], zone=parent_zone, index=0, record_name="parent.com.", + input_name="parent.com.", record_data="4.5.6.7") + finally: + clear_zoneid_rsid_tuple_list(to_delete, client) + + +def test_create_batch_change_partial_failure(shared_zone_test_context): + """ + Test batch change status with partial failures + """ + client = shared_zone_test_context.ok_vinyldns_client + + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("will-succeed.ok.", address="4.5.6.7"), + get_change_A_AAAA_json("direct-to-backend.ok.", address="4.5.6.7") # this record will fail in processing + ] + } + + to_delete = [] + + try: + dns_add(shared_zone_test_context.ok_zone, "direct-to-backend", 200, "A", "1.2.3.4") + result = client.create_batch_change(batch_change_input, status=202) + completed_batch = client.wait_until_batch_change_completed(result) + record_set_list = [(change['zoneId'], change['recordSetId']) for change in completed_batch['changes'] if change['status'] == "Complete"] + to_delete = set(record_set_list) # set here because multiple items in the batch combine to one RS + + assert_that(completed_batch['status'], is_("PartialFailure")) + + finally: + clear_zoneid_rsid_tuple_list(to_delete, client) + dns_delete(shared_zone_test_context.ok_zone, "direct-to-backend", "A") + + +def test_create_batch_change_failed(shared_zone_test_context): + """ + Test batch change status with all failures + """ + client = shared_zone_test_context.ok_vinyldns_client + + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("backend-foo.ok.", address="4.5.6.7"), + get_change_A_AAAA_json("backend-already-exists.ok.", address="4.5.6.7") + ] + } + + try: + # both of these records already exist in the backend, but are not synced in zone + dns_add(shared_zone_test_context.ok_zone, "backend-foo", 200, "A", "1.2.3.4") + dns_add(shared_zone_test_context.ok_zone, "backend-already-exists", 200, "A", "1.2.3.4") + result = client.create_batch_change(batch_change_input, status=202) + completed_batch = client.wait_until_batch_change_completed(result) + + assert_that(completed_batch['status'], is_("Failed")) + + finally: + dns_delete(shared_zone_test_context.ok_zone, "backend-foo", "A") + dns_delete(shared_zone_test_context.ok_zone, "backend-already-exists", "A") + + +def test_empty_batch_fails(shared_zone_test_context): + """ + Test creating batch without any changes fails with + """ + + batch_change_input = { + "comments": "this should fail processing", + "changes": [] + } + + error = shared_zone_test_context.ok_vinyldns_client.create_batch_change(batch_change_input, status=422) + assert_that(error, is_("Batch change contained no changes. Batch change must have at least one change, up to a maximum of 20 changes.")) + + +def test_create_batch_exceeding_change_limit_fails(shared_zone_test_context): + """ + Test that creating a batch exceeding the change limit fails with ChangeLimitExceeded + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "changes": [] + } + for x in range(100): + batch_change_input['changes'].append(get_change_A_AAAA_json("ok.", address=("1.2.3." + str(x)))) + + errors = client.create_batch_change(batch_change_input, status=413) + + assert_that(errors, is_("Cannot request more than 20 changes in a single batch change request")) + + +def test_create_batch_change_without_changes_fails(shared_zone_test_context): + """ + Test creating a batch change with missing changes fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional" + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes"]) + + +def test_create_batch_change_with_missing_change_type_fails(shared_zone_test_context): + """ + Test creating a batch change with missing change type fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "inputName": "thing.thing.com.", + "type": "A", + "ttl": 200, + "record": { + "address": "4.5.6.7" + } + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes.changeType"]) + + +def test_create_batch_change_with_invalid_change_type_fails(shared_zone_test_context): + """ + Test creating a batch change with invalid change type fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "InvalidChangeType", + "data": { + "inputName": "thing.thing.com.", + "type": "A", + "ttl": 200, + "record": { + "address": "4.5.6.7" + } + } + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Invalid ChangeInputType"]) + + +def test_create_batch_change_with_missing_input_name_fails(shared_zone_test_context): + """ + Test creating a batch change without an inputName fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "type": "A", + "ttl": 200, + "record": { + "address": "4.5.6.7" + } + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes.inputName"]) + + +def test_create_batch_change_with_unsupported_record_type_fails(shared_zone_test_context): + """ + Test creating a batch change with unsupported record type fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "inputName": "thing.thing.com.", + "type": "UNKNOWN", + "ttl": 200, + "record": { + "address": "4.5.6.7" + } + } + ] + } + + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Unsupported type UNKNOWN, valid types include: A, AAAA, CNAME, PTR, TXT, and MX"]) + + +def test_create_batch_change_with_invalid_record_type_fails(shared_zone_test_context): + """ + Test creating a batch change with invalid record type fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("thing.thing.com.", "B", address="4.5.6.7") + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Invalid RecordType"]) + + +def test_create_batch_change_with_missing_ttl_fails(shared_zone_test_context): + """ + Test creating a batch change without a ttl fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "inputName": "thing.thing.com.", + "type": "A", + "record": { + "address": "4.5.6.7" + } + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes.ttl"]) + + +def test_create_batch_change_with_missing_record_fails(shared_zone_test_context): + """ + Test creating a batch change without a record fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "inputName": "thing.thing.com.", + "type": "A", + "ttl": 200 + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes.record.address"]) + + +def test_create_batch_change_with_empty_record_fails(shared_zone_test_context): + """ + Test creating a batch change with empty record fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "inputName": "thing.thing.com.", + "type": "A", + "ttl": 200, + "record": {} + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing A.address"]) + + +def test_create_batch_change_with_bad_A_record_data_fails(shared_zone_test_context): + """ + Test creating a batch change with malformed A record address fails + """ + client = shared_zone_test_context.ok_vinyldns_client + bad_A_data_request = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("thing.thing.com.", address="bad address") + ] + } + errors = client.create_batch_change(bad_A_data_request, status=400) + + assert_error(errors, error_messages=["A must be a valid IPv4 Address"]) + + +def test_create_batch_change_with_bad_AAAA_record_data_fails(shared_zone_test_context): + """ + Test creating a batch change with malformed AAAA record address fails + """ + client = shared_zone_test_context.ok_vinyldns_client + bad_AAAA_data_request = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("thing.thing.com.", record_type="AAAA", address="bad address") + ] + } + errors = client.create_batch_change(bad_AAAA_data_request, status=400) + + assert_error(errors, error_messages=["AAAA must be a valid IPv6 Address"]) + + +def test_create_batch_change_with_incorrect_CNAME_record_attribute_fails(shared_zone_test_context): + """ + Test creating a batch change with incorrect CNAME record attribute fails + """ + client = shared_zone_test_context.ok_vinyldns_client + bad_CNAME_data_request = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "inputName": "bizz.bazz.", + "type": "CNAME", + "ttl": 200, + "record": { + "address": "buzz." + } + } + ] + } + errors = client.create_batch_change(bad_CNAME_data_request, status=400)['errors'] + + assert_that(errors, contains("Missing CNAME.cname")) + + +def test_create_batch_change_with_incorrect_PTR_record_attribute_fails(shared_zone_test_context): + """ + Test creating a batch change with incorrect PTR record attribute fails + """ + client = shared_zone_test_context.ok_vinyldns_client + bad_PTR_data_request = { + "comments": "this is optional", + "changes": [ + { + "changeType": "Add", + "inputName": "4.5.6.7", + "type": "PTR", + "ttl": 200, + "record": { + "address": "buzz." + } + } + ] + } + errors = client.create_batch_change(bad_PTR_data_request, status=400)['errors'] + + assert_that(errors, contains("Missing PTR.ptrdname")) + + +def test_create_batch_change_with_bad_CNAME_record_attribute_fails(shared_zone_test_context): + """ + Test creating a batch change with malformed CNAME record fails + """ + client = shared_zone_test_context.ok_vinyldns_client + bad_CNAME_data_request = { + "comments": "this is optional", + "changes": [ + get_change_CNAME_json(input_name="bizz.baz.", cname="s." + "s" * 256) + ] + } + errors = client.create_batch_change(bad_CNAME_data_request, status=400) + + assert_error(errors, error_messages=["CNAME domain name must not exceed 255 characters"]) + + +def test_create_batch_change_with_bad_PTR_record_attribute_fails(shared_zone_test_context): + """ + Test creating a batch change with malformed PTR record fails + """ + client = shared_zone_test_context.ok_vinyldns_client + bad_PTR_data_request = { + "comments": "this is optional", + "changes": [ + get_change_PTR_json("4.5.6.7", ptrdname="s" * 256) + ] + } + errors = client.create_batch_change(bad_PTR_data_request, status=400) + + assert_error(errors, error_messages=["PTR must be less than 255 characters"]) + + +def test_create_batch_change_with_missing_input_name_for_delete_fails(shared_zone_test_context): + """ + Test creating a batch change without an inputName for DeleteRecordSet fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "DeleteRecordSet", + "type": "A" + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes.inputName"]) + + +def test_create_batch_change_with_missing_record_type_for_delete_fails(shared_zone_test_context): + """ + Test creating a batch change without record type for DeleteRecordSet fails + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + { + "changeType": "DeleteRecordSet", + "inputName": "thing.thing.com." + } + ] + } + errors = client.create_batch_change(batch_change_input, status=400) + + assert_error(errors, error_messages=["Missing BatchChangeInput.changes.type"]) + + +def test_mx_recordtype_cannot_have_invalid_preference(shared_zone_test_context): + """ + Test batch fails with bad mx preference + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + + batch_change_input_low = { + "comments": "this is optional", + "changes": [ + get_change_MX_json("too-small.ok.", preference=-1) + ] + } + + batch_change_input_high = { + "comments": "this is optional", + "changes": [ + get_change_MX_json("too-big.ok.", preference=65536) + ] + } + + error_low = ok_client.create_batch_change(batch_change_input_low, status=400) + error_high = ok_client.create_batch_change(batch_change_input_high, status=400) + + assert_error(error_low, error_messages=["MX.preference must be a 16 bit integer"]) + assert_error(error_high, error_messages=["MX.preference must be a 16 bit integer"]) + + +def test_create_batch_change_with_invalid_duplicate_record_names_fails(shared_zone_test_context): + """ + Test creating a batch change that contains a CNAME record and another record with the same name fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + rs_A_delete = get_recordset_json(shared_zone_test_context.ok_zone, "delete", "A", [{"address": "10.1.1.1"}]) + rs_CNAME_delete = get_recordset_json(shared_zone_test_context.ok_zone, "delete-this", "CNAME", [{"cname": "cname."}]) + + to_create = [rs_A_delete, rs_CNAME_delete] + to_delete = [] + + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("thing.ok.", address="4.5.6.7"), + get_change_CNAME_json("thing.ok"), + get_change_A_AAAA_json("delete.ok", change_type="DeleteRecordSet"), + get_change_CNAME_json("delete.ok"), + get_change_A_AAAA_json("delete-this.ok", address="4.5.6.7"), + get_change_CNAME_json("delete-this.ok", change_type="DeleteRecordSet") + ] + } + + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + assert_successful_change_in_error_response(response[0], input_name="thing.ok.", record_data="4.5.6.7") + assert_failed_change_in_error_response(response[1], input_name="thing.ok.", record_type="CNAME", record_data="test.com.", + error_messages=['Record Name "thing.ok." Not Unique In Batch Change:' + ' cannot have multiple "CNAME" records with the same name.']) + assert_successful_change_in_error_response(response[2], input_name="delete.ok.", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[3], input_name="delete.ok.", record_type="CNAME", record_data="test.com.") + assert_successful_change_in_error_response(response[4], input_name="delete-this.ok.", record_data="4.5.6.7") + assert_successful_change_in_error_response(response[5], input_name="delete-this.ok.", change_type="DeleteRecordSet", record_type="CNAME") + + finally: + clear_recordset_list(to_delete, client) + + +def test_create_batch_change_with_readonly_user_fails(shared_zone_test_context): + """ + Test creating a batch change with an read-only user fails (acl rules on zone) + """ + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = generate_acl_rule('Read', groupId=shared_zone_test_context.dummy_group['id'], recordMask='.*', recordTypes=['A', 'AAAA']) + + delete_rs = get_recordset_json(shared_zone_test_context.ok_zone, "delete", "A", [{"address": "127.0.0.1"}], 300) + update_rs = get_recordset_json(shared_zone_test_context.ok_zone, "update", "A", [{"address": "127.0.0.1"}], 300) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("relative.ok.", address="4.5.6.7"), + get_change_A_AAAA_json("delete.ok.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update.ok.", address="1.2.3.4"), + get_change_A_AAAA_json("update.ok.", change_type="DeleteRecordSet") + ] + } + + to_delete = [] + try: + add_ok_acl_rules(shared_zone_test_context, acl_rule) + + for rs in [delete_rs, update_rs]: + create_result = ok_client.create_recordset(rs, status=202) + to_delete.append(ok_client.wait_until_recordset_change_status(create_result, 'Complete')) + + errors = dummy_client.create_batch_change(batch_change_input, status=400) + + assert_failed_change_in_error_response(errors[0], input_name="relative.ok.", record_data="4.5.6.7", error_messages=['User \"dummy\" is not authorized.']) + assert_failed_change_in_error_response(errors[1], input_name="delete.ok.", change_type="DeleteRecordSet", record_data="4.5.6.7", + error_messages=['User "dummy" is not authorized.']) + assert_failed_change_in_error_response(errors[2], input_name="update.ok.", record_data="1.2.3.4", error_messages=['User \"dummy\" is not authorized.']) + assert_failed_change_in_error_response(errors[3], input_name="update.ok.", change_type="DeleteRecordSet", record_data=None, + error_messages=['User \"dummy\" is not authorized.']) + finally: + clear_ok_acl_rules(shared_zone_test_context) + clear_recordset_list(to_delete, ok_client) + + +def test_a_recordtype_add_checks(shared_zone_test_context): + """ + Test all add validations performed on A records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + + existing_a = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-A", "A", [{"address": "10.1.1.1"}], 100) + existing_cname = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-Cname", "CNAME", [{"cname": "cname.data."}], 100) + + batch_change_input = { + "changes": [ + # valid changes + get_change_A_AAAA_json("good-record.parent.com.", address="1.2.3.4"), + get_change_A_AAAA_json("summed-record.parent.com.", address="1.2.3.4"), + get_change_A_AAAA_json("summed-record.parent.com.", address="5.6.7.8"), + + # input validation failures + get_change_A_AAAA_json("bad-ttl-and-invalid-name$.parent.com.", ttl=29, address="1.2.3.4"), + get_change_A_AAAA_json("reverse-zone.30.172.in-addr.arpa.", address="1.2.3.4"), + + # zone discovery failures + get_change_A_AAAA_json("no.subzone.parent.com.", address="1.2.3.4"), + get_change_A_AAAA_json("no.zone.at.all.", address="1.2.3.4"), + + # context validation failures + get_change_CNAME_json("cname-duplicate.parent.com."), + get_change_A_AAAA_json("cname-duplicate.parent.com.", address="1.2.3.4"), + get_change_A_AAAA_json("existing-a.parent.com.", address="1.2.3.4"), + get_change_A_AAAA_json("existing-cname.parent.com.", address="1.2.3.4"), + get_change_A_AAAA_json("user-add-unauthorized.dummy.", address="1.2.3.4") + ] + } + + to_create = [existing_a, existing_cname] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="good-record.parent.com.", record_data="1.2.3.4") + assert_successful_change_in_error_response(response[1], input_name="summed-record.parent.com.", record_data="1.2.3.4") + assert_successful_change_in_error_response(response[2], input_name="summed-record.parent.com.", record_data="5.6.7.8") + + # ttl, domain name, reverse zone input validations + assert_failed_change_in_error_response(response[3], input_name="bad-ttl-and-invalid-name$.parent.com.", ttl=29, record_data="1.2.3.4", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "bad-ttl-and-invalid-name$.parent.com.", ' + 'valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[4], input_name="reverse-zone.30.172.in-addr.arpa.", record_data="1.2.3.4", + error_messages=["Invalid Record Type In Reverse Zone: record with name \"reverse-zone.30.172.in-addr.arpa.\" and type \"A\" is not allowed in a reverse zone."]) + + # zone discovery failures + assert_failed_change_in_error_response(response[5], input_name="no.subzone.parent.com.", record_data="1.2.3.4", + error_messages=['Zone Discovery Failed: zone for "no.subzone.parent.com." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + assert_failed_change_in_error_response(response[6], input_name="no.zone.at.all.", record_data="1.2.3.4", + error_messages=['Zone Discovery Failed: zone for "no.zone.at.all." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + + # context validations: duplicate name failure is always on the cname + assert_failed_change_in_error_response(response[7], input_name="cname-duplicate.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Record Name \"cname-duplicate.parent.com.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + assert_successful_change_in_error_response(response[8], input_name="cname-duplicate.parent.com.", record_data="1.2.3.4") + + # context validations: conflicting recordsets, unauthorized error + assert_failed_change_in_error_response(response[9], input_name="existing-a.parent.com.", record_data="1.2.3.4", + error_messages=["Record \"existing-a.parent.com.\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + assert_failed_change_in_error_response(response[10], input_name="existing-cname.parent.com.", record_data="1.2.3.4", + error_messages=["CNAME Conflict: CNAME record names must be unique. Existing record with name \"existing-cname.parent.com.\" and type \"CNAME\" conflicts with this record."]) + assert_failed_change_in_error_response(response[11], input_name="user-add-unauthorized.dummy.", record_data="1.2.3.4", + error_messages=["User \"ok\" is not authorized."]) + + finally: + clear_recordset_list(to_delete, client) + + +def test_a_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on A records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + dummy_zone = shared_zone_test_context.dummy_zone + + rs_delete_ok = get_recordset_json(ok_zone, "delete", "A", [{'address': '1.1.1.1'}]) + rs_update_ok = get_recordset_json(ok_zone, "update", "A", [{'address': '1.1.1.1'}]) + rs_delete_dummy = get_recordset_json(dummy_zone, "delete-unauthorized", "A", [{'address': '1.1.1.1'}]) + rs_update_dummy = get_recordset_json(dummy_zone, "update-unauthorized", "A", [{'address': '1.1.1.1'}]) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes + get_change_A_AAAA_json("delete.ok.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update.ok.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update.ok.", ttl=300), + + # input validations failures + get_change_A_AAAA_json("$invalid.host.name.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("reverse.zone.in-addr.arpa.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("$another.invalid.host.name.", ttl=300), + get_change_A_AAAA_json("$another.invalid.host.name.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("another.reverse.zone.in-addr.arpa.", ttl=10), + get_change_A_AAAA_json("another.reverse.zone.in-addr.arpa.", change_type="DeleteRecordSet"), + + # zone discovery failures + get_change_A_AAAA_json("zone.discovery.error.", change_type="DeleteRecordSet"), + + # context validation failures: record does not exist, not authorized + get_change_A_AAAA_json("non-existent.ok.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("delete-unauthorized.dummy.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update-unauthorized.dummy.", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update-unauthorized.dummy.", ttl=300) + ] + } + + to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy] + to_delete = [] + + try: + for rs in to_create: + if rs['zoneId'] == dummy_zone['id']: + create_client = dummy_client + else: + create_client = ok_client + + create_rs = create_client.create_recordset(rs, status=202) + to_delete.append(create_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + # Confirm that record set doesn't already exist + ok_client.get_recordset(ok_zone['id'], 'non-existent', status=404) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # valid changes + assert_successful_change_in_error_response(response[0], input_name="delete.ok.", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], input_name="update.ok.", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[2], input_name="update.ok.", ttl=300) + + # input validations failures + assert_failed_change_in_error_response(response[3], input_name="$invalid.host.name.", change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "$invalid.host.name.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[4], input_name="reverse.zone.in-addr.arpa.", change_type="DeleteRecordSet", + error_messages=['Invalid Record Type In Reverse Zone: record with name "reverse.zone.in-addr.arpa." and type "A" is not allowed in a reverse zone.']) + assert_failed_change_in_error_response(response[5], input_name="$another.invalid.host.name.", ttl=300, + error_messages=['Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[6], input_name="$another.invalid.host.name.", change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[7], input_name="another.reverse.zone.in-addr.arpa.", ttl=10, + error_messages=['Invalid Record Type In Reverse Zone: record with name "another.reverse.zone.in-addr.arpa." and type "A" is not allowed in a reverse zone.', + 'Invalid TTL: "10", must be a number between 30 and 2147483647.']) + assert_failed_change_in_error_response(response[8], input_name="another.reverse.zone.in-addr.arpa.", change_type="DeleteRecordSet", + error_messages=['Invalid Record Type In Reverse Zone: record with name "another.reverse.zone.in-addr.arpa." and type "A" is not allowed in a reverse zone.']) + + # zone discovery failures + assert_failed_change_in_error_response(response[9], input_name="zone.discovery.error.", change_type="DeleteRecordSet", + error_messages=['Zone Discovery Failed: zone for "zone.discovery.error." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + + # context validation failures: record does not exist, not authorized + assert_failed_change_in_error_response(response[10], input_name="non-existent.ok.", change_type="DeleteRecordSet", + error_messages=['Record "non-existent.ok." Does Not Exist: cannot delete a record that does not exist.']) + assert_failed_change_in_error_response(response[11], input_name="delete-unauthorized.dummy.", change_type="DeleteRecordSet", + error_messages=['User \"ok\" is not authorized.']) + assert_failed_change_in_error_response(response[12], input_name="update-unauthorized.dummy.", change_type="DeleteRecordSet", + error_messages=['User \"ok\" is not authorized.']) + assert_failed_change_in_error_response(response[13], input_name="update-unauthorized.dummy.", ttl=300, error_messages=['User \"ok\" is not authorized.']) + + finally: + # Clean up updates + dummy_deletes = [rs for rs in to_delete if rs['zone']['id'] == dummy_zone['id']] + ok_deletes = [rs for rs in to_delete if rs['zone']['id'] != dummy_zone['id']] + clear_recordset_list(dummy_deletes, dummy_client) + clear_recordset_list(ok_deletes, ok_client) + + +def test_aaaa_recordtype_add_checks(shared_zone_test_context): + """ + Test all add validations performed on AAAA records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + + existing_aaaa = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-AAAA", "AAAA", [{"address": "1::1"}], 100) + existing_cname = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-Cname", "CNAME", [{"cname": "cname.data."}], 100) + + batch_change_input = { + "changes": [ + # valid changes + get_change_A_AAAA_json("good-record.parent.com.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("summed-record.parent.com.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("summed-record.parent.com.", record_type="AAAA", address="1::2"), + + # input validation failures + get_change_A_AAAA_json("bad-ttl-and-invalid-name$.parent.com.", ttl=29, record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("reverse-zone.1.2.3.ip6.arpa.", record_type="AAAA", address="1::1"), + + # zone discovery failures + get_change_A_AAAA_json("no.subzone.parent.com.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("no.zone.at.all.", record_type="AAAA", address="1::1"), + + # context validation failures + get_change_CNAME_json("cname-duplicate.parent.com."), + get_change_A_AAAA_json("cname-duplicate.parent.com.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("existing-aaaa.parent.com.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("existing-cname.parent.com.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("user-add-unauthorized.dummy.", record_type="AAAA", address="1::1") + ] + } + + to_create = [existing_aaaa, existing_cname] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="good-record.parent.com.", record_type="AAAA", record_data="1::1") + assert_successful_change_in_error_response(response[1], input_name="summed-record.parent.com.", record_type="AAAA", record_data="1::1") + assert_successful_change_in_error_response(response[2], input_name="summed-record.parent.com.", record_type="AAAA", record_data="1::2") + + # ttl, domain name, reverse zone input validations + assert_failed_change_in_error_response(response[3], input_name="bad-ttl-and-invalid-name$.parent.com.", ttl=29, record_type="AAAA", record_data="1::1", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "bad-ttl-and-invalid-name$.parent.com.", ' + 'valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[4], input_name="reverse-zone.1.2.3.ip6.arpa.", record_type="AAAA", record_data="1::1", + error_messages=["Invalid Record Type In Reverse Zone: record with name \"reverse-zone.1.2.3.ip6.arpa.\" and type \"AAAA\" is not allowed in a reverse zone."]) + + # zone discovery failures + assert_failed_change_in_error_response(response[5], input_name="no.subzone.parent.com.", record_type="AAAA", record_data="1::1", + error_messages=["Zone Discovery Failed: zone for \"no.subzone.parent.com.\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + assert_failed_change_in_error_response(response[6], input_name="no.zone.at.all.", record_type="AAAA", record_data="1::1", + error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validations: duplicate name failure (always on the cname), conflicting recordsets, unauthorized error + assert_failed_change_in_error_response(response[7], input_name="cname-duplicate.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Record Name \"cname-duplicate.parent.com.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + assert_successful_change_in_error_response(response[8], input_name="cname-duplicate.parent.com.", record_type="AAAA", record_data="1::1") + assert_failed_change_in_error_response(response[9], input_name="existing-aaaa.parent.com.", record_type="AAAA", record_data="1::1", + error_messages=["Record \"existing-aaaa.parent.com.\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + assert_failed_change_in_error_response(response[10], input_name="existing-cname.parent.com.", record_type="AAAA", record_data="1::1", + error_messages=["CNAME Conflict: CNAME record names must be unique. Existing record with name \"existing-cname.parent.com.\" and type \"CNAME\" conflicts with this record."]) + assert_failed_change_in_error_response(response[11], input_name="user-add-unauthorized.dummy.", record_type="AAAA", record_data="1::1", + error_messages=["User \"ok\" is not authorized."]) + + finally: + clear_recordset_list(to_delete, client) + + +def test_aaaa_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on AAAA records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + dummy_zone = shared_zone_test_context.dummy_zone + + rs_delete_ok = get_recordset_json(ok_zone, "delete", "AAAA", [{"address": "1:2:3:4:5:6:7:8"}], 200) + rs_update_ok = get_recordset_json(ok_zone, "update", "AAAA", [{"address": "1:1:1:1:1:1:1:1"}], 200) + rs_delete_dummy = get_recordset_json(dummy_zone, "delete-unauthorized", "AAAA", [{"address": "1::1"}], 200) + rs_update_dummy = get_recordset_json(dummy_zone, "update-unauthorized", "AAAA", [{"address": "1:2:3:4:5:6:7:8"}], 200) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes + get_change_A_AAAA_json("delete.ok.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update.ok.", record_type="AAAA", ttl=300, address="1:2:3:4:5:6:7:8"), + get_change_A_AAAA_json("update.ok.", record_type="AAAA", change_type="DeleteRecordSet"), + + # input validations failures + get_change_A_AAAA_json("invalid-name$.ok.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("reverse.zone.in-addr.arpa.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("bad-ttl-and-invalid-name$-update.ok.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("bad-ttl-and-invalid-name$-update.ok.", ttl=29, record_type="AAAA", address="1:2:3:4:5:6:7:8"), + + # zone discovery failures + get_change_A_AAAA_json("no.zone.at.all.", record_type="AAAA", change_type="DeleteRecordSet"), + + # context validation failures + get_change_A_AAAA_json("delete-nonexistent.ok.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update-nonexistent.ok.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update-nonexistent.ok.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("delete-unauthorized.dummy.", record_type="AAAA", change_type="DeleteRecordSet"), + get_change_A_AAAA_json("update-unauthorized.dummy.", record_type="AAAA", address="1::1"), + get_change_A_AAAA_json("update-unauthorized.dummy.", record_type="AAAA", change_type="DeleteRecordSet") + ] + } + + to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy] + to_delete = [] + + try: + for rs in to_create: + if rs['zoneId'] == dummy_zone['id']: + create_client = dummy_client + else: + create_client = ok_client + + create_rs = create_client.create_recordset(rs, status=202) + to_delete.append(create_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + # Confirm that record set doesn't already exist + ok_client.get_recordset(ok_zone['id'], 'delete-nonexistent', status=404) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="delete.ok.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], ttl=300, input_name="update.ok.", record_type="AAAA", record_data="1:2:3:4:5:6:7:8") + assert_successful_change_in_error_response(response[2], input_name="update.ok.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet") + + # input validations failures: invalid input name, reverse zone error, invalid ttl + assert_failed_change_in_error_response(response[3], input_name="invalid-name$.ok.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "invalid-name$.ok.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[4], input_name="reverse.zone.in-addr.arpa.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=["Invalid Record Type In Reverse Zone: record with name \"reverse.zone.in-addr.arpa.\" and type \"AAAA\" is not allowed in a reverse zone."]) + assert_failed_change_in_error_response(response[5], input_name="bad-ttl-and-invalid-name$-update.ok.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "bad-ttl-and-invalid-name$-update.ok.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[6], input_name="bad-ttl-and-invalid-name$-update.ok.", ttl=29, record_type="AAAA", record_data="1:2:3:4:5:6:7:8", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "bad-ttl-and-invalid-name$-update.ok.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + + # zone discovery failure + assert_failed_change_in_error_response(response[7], input_name="no.zone.at.all.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validation failures: record does not exist, not authorized + assert_failed_change_in_error_response(response[8], input_name="delete-nonexistent.ok.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"delete-nonexistent.ok.\" Does Not Exist: cannot delete a record that does not exist."]) + assert_failed_change_in_error_response(response[9], input_name="update-nonexistent.ok.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"update-nonexistent.ok.\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[10], input_name="update-nonexistent.ok.", record_type="AAAA", record_data="1::1",) + assert_failed_change_in_error_response(response[11], input_name="delete-unauthorized.dummy.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"ok\" is not authorized."]) + assert_failed_change_in_error_response(response[12], input_name="update-unauthorized.dummy.", record_type="AAAA", record_data="1::1", + error_messages=["User \"ok\" is not authorized."]) + assert_failed_change_in_error_response(response[13], input_name="update-unauthorized.dummy.", record_type="AAAA", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"ok\" is not authorized."]) + + finally: + # Clean up updates + dummy_deletes = [rs for rs in to_delete if rs['zone']['id'] == dummy_zone['id']] + ok_deletes = [rs for rs in to_delete if rs['zone']['id'] != dummy_zone['id']] + clear_recordset_list(dummy_deletes, dummy_client) + clear_recordset_list(ok_deletes, ok_client) + + +def test_cname_recordtype_add_checks(shared_zone_test_context): + """ + Test all add validations performed on CNAME records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + + existing_forward = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-Forward", "A", [{"address": "1.2.3.4"}], 100) + existing_reverse = get_recordset_json(shared_zone_test_context.classless_base_zone, "0", "PTR", [{"ptrdname": "test.com."}], 100) + existing_cname = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-Cname", "CNAME", [{"cname": "cname.data."}], 100) + rs_a_to_cname_ok = get_recordset_json(ok_zone, "a-to-cname", "A", [{'address': '1.1.1.1'}]) + rs_cname_to_A_ok = get_recordset_json(ok_zone, "cname-to-a", "CNAME", [{'cname': 'test.com.'}]) + + batch_change_input = { + "changes": [ + # valid change + get_change_CNAME_json("forward-zone.parent.com."), + get_change_CNAME_json("reverse-zone.30.172.in-addr.arpa."), + + # valid changes - delete and add of same record name but different type + get_change_A_AAAA_json("a-to-cname.ok", change_type="DeleteRecordSet"), + get_change_CNAME_json("a-to-cname.ok"), + get_change_A_AAAA_json("cname-to-a.ok"), + get_change_CNAME_json("cname-to-a.ok", change_type="DeleteRecordSet"), + + # input validations failures + get_change_CNAME_json("bad-ttl-and-invalid-name$.parent.com.", ttl=29, cname="also$bad.name"), + + # zone discovery failure + get_change_CNAME_json("no.subzone.parent.com."), + + # cant be apex + get_change_CNAME_json("parent.com."), + + # context validation failures + get_change_PTR_json("192.0.2.15"), + get_change_CNAME_json("15.2.0.192.in-addr.arpa.", cname="duplicate.other.type.within.batch."), + get_change_CNAME_json("cname-duplicate.parent.com."), + get_change_CNAME_json("cname-duplicate.parent.com.", cname="duplicate.cname.type.within.batch."), + get_change_CNAME_json("existing-forward.parent.com."), + get_change_CNAME_json("existing-cname.parent.com."), + get_change_CNAME_json("0.2.0.192.in-addr.arpa.", cname="duplicate.in.db."), + get_change_CNAME_json("user-add-unauthorized.dummy.") + ] + } + + to_create = [existing_forward, existing_reverse, existing_cname, rs_a_to_cname_ok, rs_cname_to_A_ok] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="forward-zone.parent.com.", record_type="CNAME", record_data="test.com.") + assert_successful_change_in_error_response(response[1], input_name="reverse-zone.30.172.in-addr.arpa.", record_type="CNAME", record_data="test.com.") + + # successful changes - delete and add of same record name but different type + assert_successful_change_in_error_response(response[2], input_name="a-to-cname.ok.", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[3], input_name="a-to-cname.ok.", record_type="CNAME", record_data="test.com.") + assert_successful_change_in_error_response(response[4], input_name="cname-to-a.ok.") + assert_successful_change_in_error_response(response[5], input_name="cname-to-a.ok.", record_type="CNAME", change_type="DeleteRecordSet") + + # ttl, domain name, data + assert_failed_change_in_error_response(response[6], input_name="bad-ttl-and-invalid-name$.parent.com.", ttl=29, record_type="CNAME", record_data="also$bad.name.", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "bad-ttl-and-invalid-name$.parent.com.", ' + 'valid domain names must be letters, numbers, and hyphens, ' + 'joined by dots, and terminated with a dot.', + 'Invalid domain name: "also$bad.name.", ' + 'valid domain names must be letters, numbers, and hyphens, ' + 'joined by dots, and terminated with a dot.']) + # zone discovery failure + assert_failed_change_in_error_response(response[7], input_name="no.subzone.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Zone Discovery Failed: zone for \"no.subzone.parent.com.\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # CNAME cant be apex + assert_failed_change_in_error_response(response[8], input_name="parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Record \"parent.com.\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + + # context validations: duplicates in batch + assert_successful_change_in_error_response(response[9], input_name="192.0.2.15", record_type="PTR", record_data="test.com.") + assert_failed_change_in_error_response(response[10], input_name="15.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="duplicate.other.type.within.batch.", + error_messages=["Record Name \"15.2.0.192.in-addr.arpa.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + + assert_failed_change_in_error_response(response[11], input_name="cname-duplicate.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Record Name \"cname-duplicate.parent.com.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + assert_failed_change_in_error_response(response[12], input_name="cname-duplicate.parent.com.", record_type="CNAME", record_data="duplicate.cname.type.within.batch.", + error_messages=["Record Name \"cname-duplicate.parent.com.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + + # context validations: existing recordsets pre-request, unauthorized, failure on duplicate add + assert_failed_change_in_error_response(response[13], input_name="existing-forward.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["CNAME Conflict: CNAME record names must be unique. Existing record with name \"existing-forward.parent.com.\" and type \"A\" conflicts with this record."]) + assert_failed_change_in_error_response(response[14], input_name="existing-cname.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Record \"existing-cname.parent.com.\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add.", + "CNAME Conflict: CNAME record names must be unique. Existing record with name \"existing-cname.parent.com.\" and type \"CNAME\" conflicts with this record."]) + assert_failed_change_in_error_response(response[15], input_name="0.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="duplicate.in.db.", + error_messages=["CNAME Conflict: CNAME record names must be unique. Existing record with name \"0.2.0.192.in-addr.arpa.\" and type \"PTR\" conflicts with this record."]) + assert_failed_change_in_error_response(response[16], input_name="user-add-unauthorized.dummy.", record_type="CNAME", record_data="test.com.", + error_messages=["User \"ok\" is not authorized."]) + + finally: + clear_recordset_list(to_delete, client) + + +def test_cname_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on CNAME records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + dummy_zone = shared_zone_test_context.dummy_zone + classless_base_zone = shared_zone_test_context.classless_base_zone + + rs_delete_ok = get_recordset_json(ok_zone, "delete", "CNAME", [{'cname': 'test.com.'}]) + rs_update_ok = get_recordset_json(ok_zone, "update", "CNAME", [{'cname': 'test.com.'}]) + rs_delete_dummy = get_recordset_json(dummy_zone, "delete-unauthorized", "CNAME", [{'cname': 'test.com.'}]) + rs_update_dummy = get_recordset_json(dummy_zone, "update-unauthorized", "CNAME", [{'cname': 'test.com.'}]) + rs_delete_base = get_recordset_json(classless_base_zone, "200", "CNAME", [{'cname': '200.192/30.2.0.192.in-addr.arpa.'}]) + rs_update_base = get_recordset_json(classless_base_zone, "201", "CNAME", [{'cname': '201.192/30.2.0.192.in-addr.arpa.'}]) + rs_update_duplicate_add = get_recordset_json(shared_zone_test_context.parent_zone, "Existing-Cname2", "CNAME", [{"cname": "cname.data."}], 100) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes - forward zone + get_change_CNAME_json("delete.ok.", change_type="DeleteRecordSet"), + get_change_CNAME_json("update.ok.", change_type="DeleteRecordSet"), + get_change_CNAME_json("update.ok.", ttl=300), + + # valid changes - reverse zone + get_change_CNAME_json("200.2.0.192.in-addr.arpa.", change_type="DeleteRecordSet"), + get_change_CNAME_json("201.2.0.192.in-addr.arpa.", change_type="DeleteRecordSet"), + get_change_CNAME_json("201.2.0.192.in-addr.arpa.", ttl=300), + + # input validation failures + get_change_CNAME_json("$invalid.host.name.", change_type="DeleteRecordSet"), + get_change_CNAME_json("$another.invalid.host.name", change_type="DeleteRecordSet"), + get_change_CNAME_json("$another.invalid.host.name", ttl=20, cname="$another.invalid.cname."), + + # zone discovery failures + get_change_CNAME_json("zone.discovery.error.", change_type="DeleteRecordSet"), + + # context validation failures: record does not exist, not authorized, failure on update with multiple adds + get_change_CNAME_json("non-existent-delete.ok.", change_type="DeleteRecordSet"), + get_change_CNAME_json("non-existent-update.ok.", change_type="DeleteRecordSet"), + get_change_CNAME_json("non-existent-update.ok."), + get_change_CNAME_json("delete-unauthorized.dummy.", change_type="DeleteRecordSet"), + get_change_CNAME_json("update-unauthorized.dummy.", change_type="DeleteRecordSet"), + get_change_CNAME_json("update-unauthorized.dummy.", ttl=300), + get_change_CNAME_json("existing-cname2.parent.com.", change_type="DeleteRecordSet"), + get_change_CNAME_json("existing-cname2.parent.com."), + get_change_CNAME_json("existing-cname2.parent.com.", ttl=350) + ] + } + + to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy, rs_delete_base, rs_update_base, rs_update_duplicate_add] + to_delete = [] + + try: + for rs in to_create: + if rs['zoneId'] == dummy_zone['id']: + create_client = dummy_client + else: + create_client = ok_client + + create_rs = create_client.create_recordset(rs, status=202) + to_delete.append(create_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + # Confirm that record set doesn't already exist + ok_client.get_recordset(ok_zone['id'], 'non-existent', status=404) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # valid changes - forward zone + assert_successful_change_in_error_response(response[0], input_name="delete.ok.", record_type="CNAME", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], input_name="update.ok.", record_type="CNAME", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[2], input_name="update.ok.", record_type="CNAME", ttl=300, record_data="test.com.") + + # valid changes - reverse zone + assert_successful_change_in_error_response(response[3], input_name="200.2.0.192.in-addr.arpa.", record_type="CNAME", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name="201.2.0.192.in-addr.arpa.", record_type="CNAME", change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[5], input_name="201.2.0.192.in-addr.arpa.", record_type="CNAME", ttl=300, record_data="test.com.") + + # ttl, domain name, data + assert_failed_change_in_error_response(response[6], input_name="$invalid.host.name.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "$invalid.host.name.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[7], input_name="$another.invalid.host.name.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[8], input_name="$another.invalid.host.name.", ttl=20, record_type="CNAME", record_data="$another.invalid.cname.", + error_messages=['Invalid TTL: "20", must be a number between 30 and 2147483647.', + 'Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.', + 'Invalid domain name: "$another.invalid.cname.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + + # zone discovery failures + assert_failed_change_in_error_response(response[9], input_name="zone.discovery.error.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['Zone Discovery Failed: zone for "zone.discovery.error." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + + # context validation failures: record does not exist, not authorized + assert_failed_change_in_error_response(response[10], input_name="non-existent-delete.ok.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['Record "non-existent-delete.ok." Does Not Exist: cannot delete a record that does not exist.']) + assert_failed_change_in_error_response(response[11], input_name="non-existent-update.ok.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['Record "non-existent-update.ok." Does Not Exist: cannot delete a record that does not exist.']) + assert_successful_change_in_error_response(response[12], input_name="non-existent-update.ok.", record_type="CNAME", record_data="test.com.") + assert_failed_change_in_error_response(response[13], input_name="delete-unauthorized.dummy.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['User "ok" is not authorized.']) + assert_failed_change_in_error_response(response[14], input_name="update-unauthorized.dummy.", record_type="CNAME", change_type="DeleteRecordSet", + error_messages=['User "ok" is not authorized.']) + assert_failed_change_in_error_response(response[15], input_name="update-unauthorized.dummy.", record_type="CNAME", ttl=300, record_data="test.com.", error_messages=['User "ok" is not authorized.']) + assert_successful_change_in_error_response(response[16], input_name="existing-cname2.parent.com.", record_type="CNAME", change_type="DeleteRecordSet") + assert_failed_change_in_error_response(response[17], input_name="existing-cname2.parent.com.", record_type="CNAME", record_data="test.com.", + error_messages=["Record Name \"existing-cname2.parent.com.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + assert_failed_change_in_error_response(response[18], input_name="existing-cname2.parent.com.", record_type="CNAME", record_data="test.com.", ttl=350, + error_messages=["Record Name \"existing-cname2.parent.com.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + + + finally: + # Clean up updates + dummy_deletes = [rs for rs in to_delete if rs['zone']['id'] == dummy_zone['id']] + ok_deletes = [rs for rs in to_delete if rs['zone']['id'] != dummy_zone['id']] + clear_recordset_list(dummy_deletes, dummy_client) + clear_recordset_list(ok_deletes, ok_client) + + +def test_ptr_recordtype_auth_checks(shared_zone_test_context): + """ + Test all authorization validations performed on PTR records submitted in batch changes + """ + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + + no_auth_ipv4 = get_recordset_json(shared_zone_test_context.classless_base_zone, "25", "PTR", [{"ptrdname": "ptrdname.data."}], 200) + no_auth_ipv6 = get_recordset_json(shared_zone_test_context.ip6_reverse_zone, "4.3.2.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", "PTR", [{"ptrdname": "ptrdname.data."}], 200) + + batch_change_input = { + "changes": [ + get_change_PTR_json("192.0.2.5", ptrdname="not.authorized.ipv4.ptr.base."), + get_change_PTR_json("192.0.2.196", ptrdname="not.authorized.ipv4.ptr.classless.delegation."), + get_change_PTR_json("fd69:27cc:fe91::1234", ptrdname="not.authorized.ipv6.ptr."), + get_change_PTR_json("192.0.2.25", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91::1234", change_type="DeleteRecordSet") + ] + } + + to_create = [no_auth_ipv4, no_auth_ipv6] + to_delete = [] + + try: + for create_json in to_create: + create_result = ok_client.create_recordset(create_json, status=202) + to_delete.append(ok_client.wait_until_recordset_change_status(create_result, 'Complete')) + + errors = dummy_client.create_batch_change(batch_change_input, status=400) + + assert_failed_change_in_error_response(errors[0], input_name="192.0.2.5", record_type="PTR", record_data="not.authorized.ipv4.ptr.base.", + error_messages=["User \"dummy\" is not authorized."]) + assert_failed_change_in_error_response(errors[1], input_name="192.0.2.196", record_type="PTR", record_data="not.authorized.ipv4.ptr.classless.delegation.", + error_messages=["User \"dummy\" is not authorized."]) + assert_failed_change_in_error_response(errors[2], input_name="fd69:27cc:fe91::1234", record_type="PTR", record_data="not.authorized.ipv6.ptr.", + error_messages=["User \"dummy\" is not authorized."]) + assert_failed_change_in_error_response(errors[3], input_name="192.0.2.25", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"dummy\" is not authorized."]) + assert_failed_change_in_error_response(errors[4], input_name="fd69:27cc:fe91::1234", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"dummy\" is not authorized."]) + finally: + clear_recordset_list(to_delete, ok_client) + + +def test_ipv4_ptr_recordtype_add_checks(shared_zone_test_context): + """ + Perform all add, non-authorization validations performed on IPv4 PTR records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + + existing_ipv4 = get_recordset_json(shared_zone_test_context.classless_zone_delegation_zone, "193", "PTR", [{"ptrdname": "ptrdname.data."}]) + existing_cname = get_recordset_json(shared_zone_test_context.classless_base_zone, "199", "CNAME", [{"cname": "cname.data."}], 300) + + batch_change_input = { + "changes": [ + # valid change + get_change_PTR_json("192.0.2.44", ptrdname="base.vinyldns"), + get_change_PTR_json("192.0.2.198", ptrdname="delegated.vinyldns"), + + # input validation failures + get_change_PTR_json("invalidip.111."), + get_change_PTR_json("4.5.6.7", ttl=29, ptrdname="-1.2.3.4"), + + # duplicate PTR name failures + get_change_PTR_json("192.0.2.197"), + get_change_PTR_json("192.0.2.197", ptrdname="ptrdata."), + + # delegated and non-delegated PTR duplicate name checks + get_change_PTR_json("192.0.2.196"), # delegated zone + get_change_CNAME_json("196.2.0.192.in-addr.arpa"), # non-delegated zone + get_change_CNAME_json("196.192/30.2.0.192.in-addr.arpa"), # delegated zone + + get_change_PTR_json("192.0.2.55"), # non-delegated zone + get_change_CNAME_json("55.2.0.192.in-addr.arpa"), # non-delegated zone + get_change_CNAME_json("55.192/30.2.0.192.in-addr.arpa"), # delegated zone + + # zone discovery failure + get_change_PTR_json("192.0.1.192"), + + # context validation failures + get_change_PTR_json("192.0.2.193", ptrdname="existing-ptr."), + get_change_PTR_json("192.0.2.199", ptrdname="existing-cname.") + ] + } + + to_create = [existing_ipv4, existing_cname] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="192.0.2.44", record_type="PTR", record_data="base.vinyldns.") + assert_successful_change_in_error_response(response[1], input_name="192.0.2.198", record_type="PTR", record_data="delegated.vinyldns.") + + # input validation failures: invalid ip, ttl, data + assert_failed_change_in_error_response(response[2], input_name="invalidip.111.", record_type="PTR", record_data="test.com.", + error_messages=['Invalid IP address: "invalidip.111.".']) + assert_failed_change_in_error_response(response[3], input_name="4.5.6.7", ttl=29, record_type="PTR", record_data="-1.2.3.4.", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "-1.2.3.4.", ' + 'valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + + # duplicate names always fail for ptr + assert_failed_change_in_error_response(response[4], input_name="192.0.2.197", record_type="PTR", record_data="test.com.", + error_messages=['Record Name "192.0.2.197" Not Unique In Batch Change:' + ' cannot have multiple "PTR" records with the same name.']) + assert_failed_change_in_error_response(response[5], input_name="192.0.2.197", record_type="PTR", record_data="ptrdata.", + error_messages=['Record Name "192.0.2.197" Not Unique In Batch Change:' + ' cannot have multiple "PTR" records with the same name.']) + + # delegated and non-delegated PTR duplicate name checks + assert_successful_change_in_error_response(response[6], input_name="192.0.2.196", record_type="PTR", record_data="test.com.") + assert_successful_change_in_error_response(response[7], input_name="196.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="test.com.") + assert_failed_change_in_error_response(response[8], input_name="196.192/30.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="test.com.", + error_messages=['Record Name "196.192/30.2.0.192.in-addr.arpa." Not Unique In Batch Change: cannot have multiple "CNAME" records with the same name.']) + assert_successful_change_in_error_response(response[9], input_name="192.0.2.55", record_type="PTR", record_data="test.com.") + assert_failed_change_in_error_response(response[10], input_name="55.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="test.com.", + error_messages=['Record Name "55.2.0.192.in-addr.arpa." Not Unique In Batch Change: cannot have multiple "CNAME" records with the same name.']) + assert_successful_change_in_error_response(response[11], input_name="55.192/30.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="test.com.") + + # zone discovery failure + assert_failed_change_in_error_response(response[12], input_name="192.0.1.192", record_type="PTR", record_data="test.com.", + error_messages=['Zone Discovery Failed: zone for "192.0.1.192" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + + # context validations: existing cname recordset + assert_failed_change_in_error_response(response[13], input_name="192.0.2.193", record_type="PTR", record_data="existing-ptr.", + error_messages=['Record "192.0.2.193" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add.']) + assert_failed_change_in_error_response(response[14], input_name="192.0.2.199", record_type="PTR", record_data="existing-cname.", + error_messages=['CNAME Conflict: CNAME record names must be unique. Existing record with name "192.0.2.199" and type "CNAME" conflicts with this record.']) + + finally: + clear_recordset_list(to_delete, client) + + +def test_ipv4_ptr_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on ipv4 PTR records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + base_zone = shared_zone_test_context.classless_base_zone + delegated_zone = shared_zone_test_context.classless_zone_delegation_zone + + rs_delete_ipv4 = get_recordset_json(base_zone, "25", "PTR", [{"ptrdname": "delete.ptr."}], 200) + rs_update_ipv4 = get_recordset_json(delegated_zone, "193", "PTR", [{"ptrdname": "update.ptr."}], 200) + rs_replace_cname = get_recordset_json(base_zone, "21", "CNAME", [{"cname": "replace.cname."}], 200) + rs_replace_ptr = get_recordset_json(base_zone, "17", "PTR", [{"ptrdname": "replace.ptr."}], 200) + rs_update_ipv4_fail = get_recordset_json(base_zone, "9", "PTR", [{"ptrdname": "failed-update.ptr."}], 200) + rs_update_ipv4_double_update = get_recordset_json(shared_zone_test_context.classless_base_zone, "50", "PTR", [{"ptrdname": "ptrdname.data."}]) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes ipv4 + get_change_PTR_json("192.0.2.25", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.193", ttl=300, ptrdname="has-updated.ptr."), + get_change_PTR_json("192.0.2.193", change_type="DeleteRecordSet"), + + # valid changes: delete and add of same record name but different type + get_change_CNAME_json("21.2.0.192.in-addr.arpa", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.21", ptrdname="replace-cname.ptr."), + get_change_CNAME_json("17.2.0.192.in-addr.arpa", cname="replace-ptr.cname."), + get_change_PTR_json("192.0.2.17", change_type="DeleteRecordSet"), + + # input validations failures + get_change_PTR_json("1.1.1", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.", ttl=29, ptrdname="failed-update$.ptr"), + + # zone discovery failures + get_change_PTR_json("192.0.1.25", change_type="DeleteRecordSet"), + + # context validation failures + get_change_PTR_json("192.0.2.199", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.200", ttl=300, ptrdname="has-updated.ptr."), + get_change_PTR_json("192.0.2.200", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.50", change_type="DeleteRecordSet"), + get_change_PTR_json("192.0.2.50"), + get_change_PTR_json("192.0.2.50", ttl=350) + ] + } + + to_create = [rs_delete_ipv4, rs_update_ipv4, rs_replace_cname, rs_replace_ptr, rs_update_ipv4_fail, rs_update_ipv4_double_update] + to_delete = [] + + try: + for rs in to_create: + create_rs = ok_client.create_recordset(rs, status=202) + to_delete.append(ok_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="192.0.2.25", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], ttl=300, input_name="192.0.2.193", record_type="PTR", record_data="has-updated.ptr.") + assert_successful_change_in_error_response(response[2], input_name="192.0.2.193", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + + #successful changes: add and delete of same record name but different type + assert_successful_change_in_error_response(response[3], input_name="21.2.0.192.in-addr.arpa.", record_type="CNAME", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[4], input_name="192.0.2.21", record_type="PTR", record_data="replace-cname.ptr.") + assert_successful_change_in_error_response(response[5], input_name="17.2.0.192.in-addr.arpa.", record_type="CNAME", record_data="replace-ptr.cname.") + assert_successful_change_in_error_response(response[6], input_name="192.0.2.17", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + + # input validations failures: invalid IP, ttl, and record data + assert_failed_change_in_error_response(response[7], input_name="1.1.1", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=['Invalid IP address: "1.1.1".']) + assert_failed_change_in_error_response(response[8], input_name="192.0.2.", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=['Invalid IP address: "192.0.2.".']) + assert_failed_change_in_error_response(response[9], ttl=29, input_name="192.0.2.", record_type="PTR", record_data="failed-update$.ptr.", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid IP address: "192.0.2.".', + 'Invalid domain name: "failed-update$.ptr.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + + # zone discovery failure + assert_failed_change_in_error_response(response[10], input_name="192.0.1.25", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["Zone Discovery Failed: zone for \"192.0.1.25\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validation failures: record does not exist, failure on update with double add + assert_failed_change_in_error_response(response[11], input_name="192.0.2.199", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"192.0.2.199\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[12], ttl=300, input_name="192.0.2.200", record_type="PTR", record_data="has-updated.ptr.") + assert_failed_change_in_error_response(response[13], input_name="192.0.2.200", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"192.0.2.200\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[14], input_name="192.0.2.50", record_type="PTR", change_type="DeleteRecordSet"), + assert_failed_change_in_error_response(response[15], input_name="192.0.2.50", record_type="PTR", record_data="test.com.", + error_messages=['Record Name "192.0.2.50" Not Unique In Batch Change: cannot have multiple "PTR" records with the same name.']) + assert_failed_change_in_error_response(response[16], input_name="192.0.2.50", record_type="PTR", record_data="test.com.", ttl=350, + error_messages=['Record Name "192.0.2.50" Not Unique In Batch Change: cannot have multiple "PTR" records with the same name.']) + + finally: + clear_recordset_list(to_delete, ok_client) + + +def test_ipv6_ptr_recordtype_add_checks(shared_zone_test_context): + """ + Test all add, non-authorization validations performed on IPv6 PTR records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + + existing_ptr = get_recordset_json(shared_zone_test_context.ip6_reverse_zone, "f.f.f.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", "PTR", [{"ptrdname": "test.com."}], 100) + + batch_change_input = { + "changes": [ + # valid change + get_change_PTR_json("fd69:27cc:fe91::1234"), + + # input validation failures + get_change_PTR_json("fd69:27cc:fe91::abe", ttl=29), + get_change_PTR_json("fd69:27cc:fe91::bae", ptrdname="$malformed.hostname."), + get_change_PTR_json("fd69:27cc:fe91de::ab", ptrdname="malformed.ip.address."), + + # zone discovery failure + get_change_PTR_json("fedc:ba98:7654::abc", ptrdname="zone.discovery.error."), + + # context validation failures + get_change_PTR_json("fd69:27cc:fe91::abc", ptrdname="duplicate.record1."), + get_change_PTR_json("fd69:27cc:fe91::abc", ptrdname="duplicate.record2."), + get_change_PTR_json("fd69:27cc:fe91::ffff", ptrdname="existing.ptr.") + ] + } + + to_create = [existing_ptr] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="fd69:27cc:fe91::1234", record_type="PTR", record_data="test.com.") + + # independent validations: bad TTL, malformed host name/IP address, duplicate record + assert_failed_change_in_error_response(response[1], input_name="fd69:27cc:fe91::abe", ttl=29, record_type="PTR", record_data="test.com.", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.']) + assert_failed_change_in_error_response(response[2], input_name="fd69:27cc:fe91::bae", record_type="PTR", record_data="$malformed.hostname.", + error_messages=['Invalid domain name: "$malformed.hostname.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[3], input_name="fd69:27cc:fe91de::ab", record_type="PTR", record_data="malformed.ip.address.", + error_messages=['Invalid IP address: "fd69:27cc:fe91de::ab".']) + + # zone discovery failure + assert_failed_change_in_error_response(response[4], input_name="fedc:ba98:7654::abc", record_type="PTR", record_data="zone.discovery.error.", + error_messages=["Zone Discovery Failed: zone for \"fedc:ba98:7654::abc\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validations: duplicates in batch, existing record sets pre-request + assert_failed_change_in_error_response(response[5], input_name="fd69:27cc:fe91::abc", record_type="PTR", record_data="duplicate.record1.", + error_messages=["Record Name \"fd69:27cc:fe91::abc\" Not Unique In Batch Change: cannot have multiple \"PTR\" records with the same name."]) + assert_failed_change_in_error_response(response[6], input_name="fd69:27cc:fe91::abc", record_type="PTR", record_data="duplicate.record2.", + error_messages=["Record Name \"fd69:27cc:fe91::abc\" Not Unique In Batch Change: cannot have multiple \"PTR\" records with the same name."]) + + assert_failed_change_in_error_response(response[7], input_name="fd69:27cc:fe91::ffff", record_type="PTR", record_data="existing.ptr.", + error_messages=["Record \"fd69:27cc:fe91::ffff\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + + finally: + clear_recordset_list(to_delete, client) + + +def test_ipv6_ptr_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on ipv6 PTR records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + ip6_reverse_zone = shared_zone_test_context.ip6_reverse_zone + + rs_delete_ipv6 = get_recordset_json(ip6_reverse_zone, "f.f.f.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", "PTR", [{"ptrdname": "delete.ptr."}], 200) + rs_update_ipv6 = get_recordset_json(ip6_reverse_zone, "2.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", "PTR", [{"ptrdname": "update.ptr."}], 200) + rs_update_ipv6_fail = get_recordset_json(ip6_reverse_zone, "8.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", "PTR", [{"ptrdname": "failed-update.ptr."}], 200) + rs_doubly_updated = get_recordset_json(shared_zone_test_context.ip6_reverse_zone, "2.2.1.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", "PTR", [{"ptrdname": "test.com."}], 100) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes ipv6 + get_change_PTR_json("fd69:27cc:fe91::ffff", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91::62", ttl=300, ptrdname="has-updated.ptr."), + get_change_PTR_json("fd69:27cc:fe91::62", change_type="DeleteRecordSet"), + + # input validations failures + get_change_PTR_json("fd69:27cc:fe91de::ab", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91de::ba", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91de::ba", ttl=29, ptrdname="failed-update$.ptr"), + + # zone discovery failures + get_change_PTR_json("fedc:ba98:7654::abc", change_type="DeleteRecordSet"), + + # context validation failures + get_change_PTR_json("fd69:27cc:fe91::60", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91::65", ttl=300, ptrdname="has-updated.ptr."), + get_change_PTR_json("fd69:27cc:fe91::65", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91::1122", change_type="DeleteRecordSet"), + get_change_PTR_json("fd69:27cc:fe91::1122"), + get_change_PTR_json("fd69:27cc:fe91::1122", ttl=350) + + ] + } + + to_create = [rs_delete_ipv6, rs_update_ipv6, rs_update_ipv6_fail, rs_doubly_updated] + to_delete = [] + + try: + for rs in to_create: + create_rs = ok_client.create_recordset(rs, status=202) + to_delete.append(ok_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="fd69:27cc:fe91::ffff", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], ttl=300, input_name="fd69:27cc:fe91::62", record_type="PTR", record_data="has-updated.ptr.") + assert_successful_change_in_error_response(response[2], input_name="fd69:27cc:fe91::62", record_type="PTR", record_data=None, change_type="DeleteRecordSet") + + # input validations failures: invalid IP, ttl, and record data + assert_failed_change_in_error_response(response[3], input_name="fd69:27cc:fe91de::ab", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=['Invalid IP address: "fd69:27cc:fe91de::ab".']) + assert_failed_change_in_error_response(response[4], input_name="fd69:27cc:fe91de::ba", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=['Invalid IP address: "fd69:27cc:fe91de::ba".']) + assert_failed_change_in_error_response(response[5], ttl=29, input_name="fd69:27cc:fe91de::ba", record_type="PTR", record_data="failed-update$.ptr.", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid IP address: "fd69:27cc:fe91de::ba".', + 'Invalid domain name: "failed-update$.ptr.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + + # zone discovery failure + assert_failed_change_in_error_response(response[6], input_name="fedc:ba98:7654::abc", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["Zone Discovery Failed: zone for \"fedc:ba98:7654::abc\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validation failures: record does not exist, failure on update with double add + assert_failed_change_in_error_response(response[7], input_name="fd69:27cc:fe91::60", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"fd69:27cc:fe91::60\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[8], ttl=300, input_name="fd69:27cc:fe91::65", record_type="PTR", record_data="has-updated.ptr.") + assert_failed_change_in_error_response(response[9], input_name="fd69:27cc:fe91::65", record_type="PTR", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"fd69:27cc:fe91::65\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[10], input_name="fd69:27cc:fe91::1122", record_type="PTR", change_type="DeleteRecordSet") + assert_failed_change_in_error_response(response[11], input_name="fd69:27cc:fe91::1122", record_type="PTR", record_data="test.com.", + error_messages=["Record Name \"fd69:27cc:fe91::1122\" Not Unique In Batch Change: cannot have multiple \"PTR\" records with the same name."]) + assert_failed_change_in_error_response(response[12], input_name="fd69:27cc:fe91::1122", record_type="PTR", record_data="test.com.", ttl=350, + error_messages=["Record Name \"fd69:27cc:fe91::1122\" Not Unique In Batch Change: cannot have multiple \"PTR\" records with the same name."]) + + + finally: + clear_recordset_list(to_delete, ok_client) + + +def test_txt_recordtype_add_checks(shared_zone_test_context): + """ + Test all add validations performed on TXT records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + + existing_txt = get_recordset_json(shared_zone_test_context.ok_zone, "existing-txt", "TXT", [{"text": "test"}], 100) + existing_cname = get_recordset_json(shared_zone_test_context.ok_zone, "existing-cname", "CNAME", [{"cname": "test."}], 100) + + batch_change_input = { + "changes": [ + # valid change + get_change_TXT_json("good-record.ok."), + + # input validation failures + get_change_TXT_json("bad-ttl-and-invalid-name$.ok.", ttl=29), + get_change_TXT_json("summed-fail.ok."), + get_change_TXT_json("summed-fail.ok.", text="test2"), + + # zone discovery failures + get_change_TXT_json("no.subzone.ok."), + get_change_TXT_json("no.zone.at.all."), + + # context validation failures + get_change_CNAME_json("cname-duplicate.ok."), + get_change_TXT_json("cname-duplicate.ok."), + get_change_TXT_json("existing-txt.ok."), + get_change_TXT_json("existing-cname.ok."), + get_change_TXT_json("user-add-unauthorized.dummy.") + ] + } + + to_create = [existing_txt, existing_cname] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="good-record.ok.", record_type="TXT", record_data="test") + + # ttl, domain name, record data + assert_failed_change_in_error_response(response[1], input_name="bad-ttl-and-invalid-name$.ok.", ttl=29, record_type="TXT", record_data="test", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "bad-ttl-and-invalid-name$.ok.", ' + 'valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[2], input_name="summed-fail.ok.", record_type="TXT", record_data="test", + error_messages=['Record Name "summed-fail.ok." Not Unique In Batch Change: cannot have multiple "TXT" records with the same name.']) + assert_failed_change_in_error_response(response[3], input_name="summed-fail.ok.", record_type="TXT", record_data="test2", + error_messages=['Record Name "summed-fail.ok." Not Unique In Batch Change: cannot have multiple "TXT" records with the same name.']) + + # zone discovery failures + assert_failed_change_in_error_response(response[4], input_name="no.subzone.ok.", record_type="TXT", record_data="test", + error_messages=['Zone Discovery Failed: zone for "no.subzone.ok." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + assert_failed_change_in_error_response(response[5], input_name="no.zone.at.all.", record_type="TXT", record_data="test", + error_messages=['Zone Discovery Failed: zone for "no.zone.at.all." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + + # context validations: cname duplicate + assert_failed_change_in_error_response(response[6], input_name="cname-duplicate.ok.", record_type="CNAME", record_data="test.com.", + error_messages=["Record Name \"cname-duplicate.ok.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + + # context validations: conflicting recordsets, unauthorized error + assert_failed_change_in_error_response(response[8], input_name="existing-txt.ok.", record_type="TXT", record_data="test", + error_messages=["Record \"existing-txt.ok.\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + assert_failed_change_in_error_response(response[9], input_name="existing-cname.ok.", record_type="TXT", record_data="test", + error_messages=["CNAME Conflict: CNAME record names must be unique. Existing record with name \"existing-cname.ok.\" and type \"CNAME\" conflicts with this record."]) + assert_failed_change_in_error_response(response[10], input_name="user-add-unauthorized.dummy.", record_type="TXT", record_data="test", + error_messages=["User \"ok\" is not authorized."]) + + finally: + clear_recordset_list(to_delete, client) + + +def test_txt_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on TXT records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + dummy_zone = shared_zone_test_context.dummy_zone + + rs_delete_ok = get_recordset_json(ok_zone, "delete", "TXT", [{"text": "test"}], 200) + rs_update_ok = get_recordset_json(ok_zone, "update", "TXT", [{"text": "test"}], 200) + rs_delete_dummy = get_recordset_json(dummy_zone, "delete-unauthorized", "TXT", [{"text": "test"}], 200) + rs_update_dummy = get_recordset_json(dummy_zone, "update-unauthorized", "TXT", [{"text": "test"}], 200) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes + get_change_TXT_json("delete.ok.", change_type="DeleteRecordSet"), + get_change_TXT_json("update.ok.", change_type="DeleteRecordSet"), + get_change_TXT_json("update.ok.", ttl=300), + + # input validations failures + get_change_TXT_json("invalid-name$.ok.", change_type="DeleteRecordSet"), + get_change_TXT_json("delete.ok.", ttl=29, text="bad-ttl"), + + # zone discovery failures + get_change_TXT_json("no.zone.at.all.", change_type="DeleteRecordSet"), + + # context validation failures + get_change_TXT_json("delete-nonexistent.ok.", change_type="DeleteRecordSet"), + get_change_TXT_json("update-nonexistent.ok.", change_type="DeleteRecordSet"), + get_change_TXT_json("update-nonexistent.ok.", text="test"), + get_change_TXT_json("delete-unauthorized.dummy.", change_type="DeleteRecordSet"), + get_change_TXT_json("update-unauthorized.dummy.", text="test"), + get_change_TXT_json("update-unauthorized.dummy.", change_type="DeleteRecordSet") + ] + } + + to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy] + to_delete = [] + + try: + for rs in to_create: + if rs['zoneId'] == dummy_zone['id']: + create_client = dummy_client + else: + create_client = ok_client + + create_rs = create_client.create_recordset(rs, status=202) + to_delete.append(create_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + # Confirm that record set doesn't already exist + ok_client.get_recordset(ok_zone['id'], 'delete-nonexistent', status=404) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="delete.ok.", record_type="TXT", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], input_name="update.ok.", record_type="TXT", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[2], ttl=300, input_name="update.ok.", record_type="TXT", record_data="test") + + # input validations failures: invalid input name, reverse zone error, invalid ttl + assert_failed_change_in_error_response(response[3], input_name="invalid-name$.ok.", record_type="TXT", record_data="test", change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "invalid-name$.ok.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[4], input_name="delete.ok.", ttl=29, record_type="TXT", record_data="bad-ttl", + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.']) + + # zone discovery failure + assert_failed_change_in_error_response(response[5], input_name="no.zone.at.all.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", + error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validation failures: record does not exist, not authorized + assert_failed_change_in_error_response(response[6], input_name="delete-nonexistent.ok.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"delete-nonexistent.ok.\" Does Not Exist: cannot delete a record that does not exist."]) + assert_failed_change_in_error_response(response[7], input_name="update-nonexistent.ok.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"update-nonexistent.ok.\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[8], input_name="update-nonexistent.ok.", record_type="TXT", record_data="test",) + assert_failed_change_in_error_response(response[9], input_name="delete-unauthorized.dummy.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"ok\" is not authorized."]) + assert_failed_change_in_error_response(response[10], input_name="update-unauthorized.dummy.", record_type="TXT", record_data="test", + error_messages=["User \"ok\" is not authorized."]) + assert_failed_change_in_error_response(response[11], input_name="update-unauthorized.dummy.", record_type="TXT", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"ok\" is not authorized."]) + + finally: + # Clean up updates + dummy_deletes = [rs for rs in to_delete if rs['zone']['id'] == dummy_zone['id']] + ok_deletes = [rs for rs in to_delete if rs['zone']['id'] != dummy_zone['id']] + clear_recordset_list(dummy_deletes, dummy_client) + clear_recordset_list(ok_deletes, ok_client) + + +def test_mx_recordtype_add_checks(shared_zone_test_context): + """ + Test all add validations performed on MX records submitted in batch changes + """ + client = shared_zone_test_context.ok_vinyldns_client + + existing_mx = get_recordset_json(shared_zone_test_context.ok_zone, "existing-mx", "MX", [{"preference": 1, "exchange": "foo.bar."}], 100) + existing_cname = get_recordset_json(shared_zone_test_context.ok_zone, "existing-cname", "CNAME", [{"cname": "test."}], 100) + + batch_change_input = { + "changes": [ + # valid change + get_change_MX_json("good-record.ok."), + + # input validation failures + get_change_MX_json("bad-ttl-and-invalid-name$.ok.", ttl=29), + get_change_MX_json("bad-exchange.ok.", exchange="foo$.bar."), + get_change_MX_json("mx.2.0.192.in-addr.arpa."), + + # zone discovery failures + get_change_MX_json("no.subzone.ok."), + get_change_MX_json("no.zone.at.all."), + + # context validation failures + get_change_CNAME_json("cname-duplicate.ok."), + get_change_MX_json("cname-duplicate.ok."), + get_change_MX_json("existing-mx.ok."), + get_change_MX_json("existing-cname.ok."), + get_change_MX_json("user-add-unauthorized.dummy.") + ] + } + + to_create = [existing_mx, existing_cname] + to_delete = [] + try: + for create_json in to_create: + create_result = client.create_recordset(create_json, status=202) + to_delete.append(client.wait_until_recordset_change_status(create_result, 'Complete')) + + response = client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="good-record.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}) + + # ttl, domain name, record data + assert_failed_change_in_error_response(response[1], input_name="bad-ttl-and-invalid-name$.ok.", ttl=29, record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.', + 'Invalid domain name: "bad-ttl-and-invalid-name$.ok.", ' + 'valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[2], input_name="bad-exchange.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo$.bar."}, + error_messages=['Invalid domain name: "foo$.bar.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[3], input_name="mx.2.0.192.in-addr.arpa.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=['Invalid Record Type In Reverse Zone: record with name "mx.2.0.192.in-addr.arpa." and type "MX" is not allowed in a reverse zone.']) + + # zone discovery failures + assert_failed_change_in_error_response(response[4], input_name="no.subzone.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=['Zone Discovery Failed: zone for "no.subzone.ok." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + assert_failed_change_in_error_response(response[5], input_name="no.zone.at.all.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=['Zone Discovery Failed: zone for "no.zone.at.all." does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS.']) + + # context validations: cname duplicate + assert_failed_change_in_error_response(response[6], input_name="cname-duplicate.ok.", record_type="CNAME", record_data="test.com.", + error_messages=["Record Name \"cname-duplicate.ok.\" Not Unique In Batch Change: cannot have multiple \"CNAME\" records with the same name."]) + + # context validations: conflicting recordsets, unauthorized error + assert_failed_change_in_error_response(response[8], input_name="existing-mx.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=["Record \"existing-mx.ok.\" Already Exists: cannot add an existing record; to update it, issue a DeleteRecordSet then an Add."]) + assert_failed_change_in_error_response(response[9], input_name="existing-cname.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=["CNAME Conflict: CNAME record names must be unique. Existing record with name \"existing-cname.ok.\" and type \"CNAME\" conflicts with this record."]) + assert_failed_change_in_error_response(response[10], input_name="user-add-unauthorized.dummy.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=["User \"ok\" is not authorized."]) + + finally: + clear_recordset_list(to_delete, client) + + +def test_mx_recordtype_update_delete_checks(shared_zone_test_context): + """ + Test all update and delete validations performed on MX records submitted in batch changes + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + dummy_zone = shared_zone_test_context.dummy_zone + + rs_delete_ok = get_recordset_json(ok_zone, "delete", "MX", [{"preference": 1, "exchange": "foo.bar."}], 200) + rs_update_ok = get_recordset_json(ok_zone, "update", "MX", [{"preference": 1, "exchange": "foo.bar."}], 200) + rs_delete_dummy = get_recordset_json(dummy_zone, "delete-unauthorized", "MX", [{"preference": 1, "exchange": "foo.bar."}], 200) + rs_update_dummy = get_recordset_json(dummy_zone, "update-unauthorized", "MX", [{"preference": 1, "exchange": "foo.bar."}], 200) + + batch_change_input = { + "comments": "this is optional", + "changes": [ + # valid changes + get_change_MX_json("delete.ok.", change_type="DeleteRecordSet"), + get_change_MX_json("update.ok.", change_type="DeleteRecordSet"), + get_change_MX_json("update.ok.", ttl=300), + + # input validations failures + get_change_MX_json("invalid-name$.ok.", change_type="DeleteRecordSet"), + get_change_MX_json("delete.ok.", ttl=29), + get_change_MX_json("bad-exchange.ok.", exchange="foo$.bar."), + get_change_MX_json("mx.2.0.192.in-addr.arpa."), + + # zone discovery failures + get_change_MX_json("no.zone.at.all.", change_type="DeleteRecordSet"), + + # context validation failures + get_change_MX_json("delete-nonexistent.ok.", change_type="DeleteRecordSet"), + get_change_MX_json("update-nonexistent.ok.", change_type="DeleteRecordSet"), + get_change_MX_json("update-nonexistent.ok.", preference=1000, exchange="foo.bar."), + get_change_MX_json("delete-unauthorized.dummy.", change_type="DeleteRecordSet"), + get_change_MX_json("update-unauthorized.dummy.", preference= 1000, exchange= "foo.bar."), + get_change_MX_json("update-unauthorized.dummy.", change_type="DeleteRecordSet") + ] + } + + to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy] + to_delete = [] + + try: + for rs in to_create: + if rs['zoneId'] == dummy_zone['id']: + create_client = dummy_client + else: + create_client = ok_client + + create_rs = create_client.create_recordset(rs, status=202) + to_delete.append(create_client.wait_until_recordset_change_status(create_rs, 'Complete')) + + # Confirm that record set doesn't already exist + ok_client.get_recordset(ok_zone['id'], 'delete-nonexistent', status=404) + + response = ok_client.create_batch_change(batch_change_input, status=400) + + # successful changes + assert_successful_change_in_error_response(response[0], input_name="delete.ok.", record_type="MX", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[1], input_name="update.ok.", record_type="MX", record_data=None, change_type="DeleteRecordSet") + assert_successful_change_in_error_response(response[2], ttl=300, input_name="update.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}) + + # input validations failures: invalid input name, reverse zone error, invalid ttl + assert_failed_change_in_error_response(response[3], input_name="invalid-name$.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, change_type="DeleteRecordSet", + error_messages=['Invalid domain name: "invalid-name$.ok.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[4], input_name="delete.ok.", ttl=29, record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.']) + assert_failed_change_in_error_response(response[5], input_name="bad-exchange.ok.", record_type="MX", record_data={"preference": 1, "exchange": "foo$.bar."}, + error_messages=['Invalid domain name: "foo$.bar.", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminated with a dot.']) + assert_failed_change_in_error_response(response[6], input_name="mx.2.0.192.in-addr.arpa.", record_type="MX", record_data={"preference": 1, "exchange": "foo.bar."}, + error_messages=['Invalid Record Type In Reverse Zone: record with name "mx.2.0.192.in-addr.arpa." and type "MX" is not allowed in a reverse zone.']) + + # zone discovery failure + assert_failed_change_in_error_response(response[7], input_name="no.zone.at.all.", record_type="MX", record_data=None, change_type="DeleteRecordSet", + error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. If zone exists, then it must be created in VinylDNS."]) + + # context validation failures: record does not exist, not authorized + assert_failed_change_in_error_response(response[8], input_name="delete-nonexistent.ok.", record_type="MX", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"delete-nonexistent.ok.\" Does Not Exist: cannot delete a record that does not exist."]) + assert_failed_change_in_error_response(response[9], input_name="update-nonexistent.ok.", record_type="MX", record_data=None, change_type="DeleteRecordSet", + error_messages=["Record \"update-nonexistent.ok.\" Does Not Exist: cannot delete a record that does not exist."]) + assert_successful_change_in_error_response(response[10], input_name="update-nonexistent.ok.", record_type="MX", record_data={"preference": 1000, "exchange": "foo.bar."},) + assert_failed_change_in_error_response(response[11], input_name="delete-unauthorized.dummy.", record_type="MX", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"ok\" is not authorized."]) + assert_failed_change_in_error_response(response[12], input_name="update-unauthorized.dummy.", record_type="MX", record_data={"preference": 1000, "exchange": "foo.bar."}, + error_messages=["User \"ok\" is not authorized."]) + assert_failed_change_in_error_response(response[13], input_name="update-unauthorized.dummy.", record_type="MX", record_data=None, change_type="DeleteRecordSet", + error_messages=["User \"ok\" is not authorized."]) + + finally: + # Clean up updates + dummy_deletes = [rs for rs in to_delete if rs['zone']['id'] == dummy_zone['id']] + ok_deletes = [rs for rs in to_delete if rs['zone']['id'] != dummy_zone['id']] + clear_recordset_list(dummy_deletes, dummy_client) + clear_recordset_list(ok_deletes, ok_client) diff --git a/modules/api/functional_test/live_tests/batch/get_batch_change_test.py b/modules/api/functional_test/live_tests/batch/get_batch_change_test.py new file mode 100644 index 0000000000..68cb7398d2 --- /dev/null +++ b/modules/api/functional_test/live_tests/batch/get_batch_change_test.py @@ -0,0 +1,75 @@ +from hamcrest import * +from utils import * + +def test_get_batch_change_success(shared_zone_test_context): + """ + Test successfully getting a batch change + """ + client = shared_zone_test_context.ok_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("parent.com.", address="4.5.6.7"), + get_change_A_AAAA_json("ok.", record_type="AAAA", address="fd69:27cc:fe91::60") + ] + } + to_delete = [] + try: + batch_change = client.create_batch_change(batch_change_input, status=202) + completed_batch = client.wait_until_batch_change_completed(batch_change) + + record_set_list = [(change['zoneId'], change['recordSetId']) for change in completed_batch['changes']] + to_delete = set(record_set_list) + + result = client.get_batch_change(batch_change['id'], status=200) + assert_that(result, is_(completed_batch)) + finally: + for result_rs in to_delete: + try: + delete_result = client.delete_recordset(result_rs[0], result_rs[1], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass + + +def test_get_batch_change_failure(shared_zone_test_context): + """ + Test that getting a batch change with invalid id returns a Not Found error + """ + client = shared_zone_test_context.ok_vinyldns_client + + error = client.get_batch_change("invalidId", status=404) + + assert_that(error, is_("Batch change with id invalidId cannot be found")) + + +def test_get_batch_change_with_unauthorized_user_fails(shared_zone_test_context): + """ + Test that getting a batch change with a user that didn't create the batch change fails + """ + client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + batch_change_input = { + "comments": "this is optional", + "changes": [ + get_change_A_AAAA_json("parent.com.", address="4.5.6.7"), + get_change_A_AAAA_json("ok.", record_type="AAAA", address="fd69:27cc:fe91::60") + ] + } + to_delete = [] + try: + batch_change = client.create_batch_change(batch_change_input, status=202) + completed_batch = client.wait_until_batch_change_completed(batch_change) + + record_set_list = [(change['zoneId'], change['recordSetId']) for change in completed_batch['changes']] + to_delete = set(record_set_list) + + error = dummy_client.get_batch_change(batch_change['id'], status=403) + assert_that(error, is_("User does not have access to item " + batch_change['id'])) + finally: + for result_rs in to_delete: + try: + delete_result = client.delete_recordset(result_rs[0], result_rs[1], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass diff --git a/modules/api/functional_test/live_tests/batch/list_batch_change_summaries_test.py b/modules/api/functional_test/live_tests/batch/list_batch_change_summaries_test.py new file mode 100644 index 0000000000..974068dcf9 --- /dev/null +++ b/modules/api/functional_test/live_tests/batch/list_batch_change_summaries_test.py @@ -0,0 +1,155 @@ +from hamcrest import * +from utils import * +import time +import pytest +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext + +class ListBatchChangeSummariesFixture(): + def __init__(self, shared_zone_test_context): + self.client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, 'listBatchSummariesAccessKey', 'listBatchSummariesSecretKey') + acl_rule = generate_acl_rule('Write', userId='list-batch-summaries-id') + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + initial_db_check = self.client.list_batch_change_summaries(status=200) + + batch_change_input_one = { + "comments": "first", + "changes": [ + get_change_CNAME_json("test-first.ok.", cname="one.") + ] + } + + batch_change_input_two = { + "comments": "second", + "changes": [ + get_change_CNAME_json("test-second.ok.", cname="two.") + ] + } + + batch_change_input_three = { + "comments": "last", + "changes": [ + get_change_CNAME_json("test-last.ok.", cname="three.") + ] + } + + batch_change_inputs = [batch_change_input_one, batch_change_input_two, batch_change_input_three] + + record_set_list = [] + self.completed_changes = [] + + if len(initial_db_check['batchChanges']) == 0: + # make some batch changes + for input in batch_change_inputs: + change = self.client.create_batch_change(input, status=202) + completed = self.client.wait_until_batch_change_completed(change) + assert_that(completed["comments"], equal_to(input["comments"])) + record_set_list += [(change['zoneId'], change['recordSetId']) for change in completed['changes']] + # sleep for consistent ordering of timestamps, must be at least one second apart + time.sleep(1) + + self.completed_changes = self.client.list_batch_change_summaries(status=200)['batchChanges'] + + assert_that(len(self.completed_changes), equal_to(3)) + else: + self.completed_changes = initial_db_check['batchChanges'] + + self.to_delete = set(record_set_list) + + def tear_down(self, shared_zone_test_context): + for result_rs in self.to_delete: + delete_result = shared_zone_test_context.ok_vinyldns_client.delete_recordset(result_rs[0], result_rs[1], status=202) + shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(delete_result, 'Complete') + clear_ok_acl_rules(shared_zone_test_context) + + def check_batch_change_summaries_page_accuracy(self, summaries_page, size, next_id=False, start_from=False, max_items=100): + # validate fields + if next_id: + assert_that(summaries_page, has_key('nextId')) + else: + assert_that(summaries_page, is_not(has_key('nextId'))) + if start_from: + assert_that(summaries_page['startFrom'], is_(start_from)) + else: + assert_that(summaries_page, is_not(has_key('startFrom'))) + assert_that(summaries_page['maxItems'], is_(max_items)) + + + # validate actual page + list_batch_change_summaries = summaries_page['batchChanges'] + assert_that(list_batch_change_summaries, has_length(size)) + + for i, summary in enumerate(list_batch_change_summaries): + assert_that(summary["userId"], equal_to("list-batch-summaries-id")) + assert_that(summary["userName"], equal_to("list-batch-summaries-user")) + assert_that(summary["comments"], equal_to(self.completed_changes[i + start_from]["comments"])) + assert_that(summary["createdTimestamp"], equal_to(self.completed_changes[i + start_from]["createdTimestamp"])) + assert_that(summary["totalChanges"], equal_to(self.completed_changes[i + start_from]["totalChanges"])) + assert_that(summary["status"], equal_to(self.completed_changes[i + start_from]["status"])) + assert_that(summary["id"], equal_to(self.completed_changes[i + start_from]["id"])) + + +@pytest.fixture(scope = "module") +def list_fixture(request, shared_zone_test_context): + fix = ListBatchChangeSummariesFixture(shared_zone_test_context) + def fin(): + fix.tear_down(shared_zone_test_context) + + request.addfinalizer(fin) + + return fix + +def test_list_batch_change_summaries_success(list_fixture): + """ + Test successfully listing all of a user's batch change summaries with no parameters + """ + client = list_fixture.client + batch_change_summaries_result = client.list_batch_change_summaries(status=200) + + list_fixture.check_batch_change_summaries_page_accuracy(batch_change_summaries_result, size=3) + + +def test_list_batch_change_summaries_with_max_items(list_fixture): + """ + Test listing a limited number of user's batch change summaries with maxItems parameter + """ + client = list_fixture.client + batch_change_summaries_result = client.list_batch_change_summaries(status=200, max_items=1) + + list_fixture.check_batch_change_summaries_page_accuracy(batch_change_summaries_result, size=1, max_items=1, next_id=1) + + +def test_list_batch_change_summaries_with_start_from(list_fixture): + """ + Test listing a limited number of user's batch change summaries with startFrom parameter + """ + client = list_fixture.client + batch_change_summaries_result = client.list_batch_change_summaries(status=200, start_from=1) + + list_fixture.check_batch_change_summaries_page_accuracy(batch_change_summaries_result, size=2, start_from=1) + + +def test_list_batch_change_summaries_with_next_id(list_fixture): + """ + Test getting user's batch change summaries with index of next batch change summary. + Apply retrieved nextId to get second page of batch change summaries. + """ + client = list_fixture.client + batch_change_summaries_result = client.list_batch_change_summaries(status=200, start_from=1, max_items=1) + + list_fixture.check_batch_change_summaries_page_accuracy(batch_change_summaries_result, size=1, start_from=1, max_items=1, next_id=2) + + next_page_result = client.list_batch_change_summaries(status=200, start_from=batch_change_summaries_result['nextId']) + + list_fixture.check_batch_change_summaries_page_accuracy(next_page_result, size=1, start_from=batch_change_summaries_result['nextId']) + + +def test_list_batch_change_summaries_with_list_batch_change_summaries_with_no_changes_passes(): + """ + Test successfully getting an empty list of summaries when user has no batch changes + """ + client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, 'listZeroSummariesAccessKey', 'listZeroSummariesSecretKey') + + batch_change_summaries_result = client.list_batch_change_summaries(status=200)["batchChanges"] + assert_that(batch_change_summaries_result, has_length(0)) diff --git a/modules/api/functional_test/live_tests/conftest.py b/modules/api/functional_test/live_tests/conftest.py new file mode 100644 index 0000000000..fb56b6a8fc --- /dev/null +++ b/modules/api/functional_test/live_tests/conftest.py @@ -0,0 +1,28 @@ +import pytest + +@pytest.fixture(scope="session") +def shared_zone_test_context(request): + from shared_zone_test_context import SharedZoneTestContext + + ctx = SharedZoneTestContext() + + def fin(): + ctx.tear_down() + + request.addfinalizer(fin) + + return ctx + + +@pytest.fixture(scope="session") +def zone_history_context(request): + from zone_history_context import ZoneHistoryContext + + context = ZoneHistoryContext() + + def fin(): + context.tear_down() + + request.addfinalizer(fin) + + return context diff --git a/modules/api/functional_test/live_tests/internal/color_test.py b/modules/api/functional_test/live_tests/internal/color_test.py new file mode 100644 index 0000000000..02301d8c35 --- /dev/null +++ b/modules/api/functional_test/live_tests/internal/color_test.py @@ -0,0 +1,14 @@ +import pytest + +from hamcrest import * +from vinyldns_python import VinylDNSClient + + +def test_color(shared_zone_test_context): + """ + Tests that the color endpoint works appropriately + """ + client = shared_zone_test_context.ok_vinyldns_client + result = client.color() + + assert_that(["green", "blue"], has_item(result)) diff --git a/modules/api/functional_test/live_tests/internal/health_test.py b/modules/api/functional_test/live_tests/internal/health_test.py new file mode 100644 index 0000000000..12d42981a0 --- /dev/null +++ b/modules/api/functional_test/live_tests/internal/health_test.py @@ -0,0 +1,13 @@ +import pytest + +from hamcrest import * +from vinyldns_python import VinylDNSClient + + +def test_health(shared_zone_test_context): + """ + Tests that the health check endpoint works + """ + client = shared_zone_test_context.ok_vinyldns_client + client.health() + diff --git a/modules/api/functional_test/live_tests/internal/ping_test.py b/modules/api/functional_test/live_tests/internal/ping_test.py new file mode 100644 index 0000000000..287bd32fac --- /dev/null +++ b/modules/api/functional_test/live_tests/internal/ping_test.py @@ -0,0 +1,14 @@ +import pytest + +from hamcrest import * +from vinyldns_python import VinylDNSClient + + +def test_ping(shared_zone_test_context): + """ + Tests that the ping endpoint works appropriately + """ + client = shared_zone_test_context.ok_vinyldns_client + result = client.ping() + + assert_that(result, is_("PONG")) diff --git a/modules/api/functional_test/live_tests/internal/status_test.py b/modules/api/functional_test/live_tests/internal/status_test.py new file mode 100644 index 0000000000..d842824469 --- /dev/null +++ b/modules/api/functional_test/live_tests/internal/status_test.py @@ -0,0 +1,74 @@ +import pytest +import time + +from hamcrest import * + +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * + + +def test_get_status_success(shared_zone_test_context): + """ + Tests that the status endpoint returns the current processing status, color, key name and version + """ + client = shared_zone_test_context.ok_vinyldns_client + result = client.get_status() + + assert_that([True, False], has_item(result['processingDisabled'])) + assert_that(["green","blue"], has_item(result['color'])) + assert_that(result['keyName'], not_none()) + assert_that(result['version'], not_none()) + +@pytest.mark.skip_production +def test_toggle_processing(shared_zone_test_context): + """ + Test that updating a zone when processing is disabled does not happen + """ + + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + + # disable processing + client.post_status(True) + + status = client.get_status() + assert_that(status['processingDisabled'], is_(True)) + + client.post_status(False) + status = client.get_status() + assert_that(status['processingDisabled'], is_(False)) + + # Create changes to make sure we can process after the toggle + # attempt to perform an update + ok_zone['email'] = 'foo@bar.com' + zone_change_result = client.update_zone(ok_zone, status=202) + + # attempt to a create a record + new_rs = { + 'zoneId': ok_zone['id'], + 'name': 'test-status-disable-processing', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + + record_change = client.create_recordset(new_rs, status=202) + assert_that(record_change['status'], is_('Pending')) + + # Make sure that the changes are processed + client.wait_until_zone_change_status(zone_change_result, 'Synced') + client.wait_until_recordset_change_status(record_change, 'Complete') + + recordset_length = len(client.list_recordsets(ok_zone['id'])['recordSets']) + + client.delete_recordset(ok_zone['id'], record_change['recordSet']['id'], status=202) + client.wait_until_recordset_deleted(ok_zone['id'], record_change['recordSet']['id']) + assert_that(client.list_recordsets(ok_zone['id'])['recordSets'], has_length(recordset_length - 1)) diff --git a/modules/api/functional_test/live_tests/membership/create_group_test.py b/modules/api/functional_test/live_tests/membership/create_group_test.py new file mode 100644 index 0000000000..4670fd72fb --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/create_group_test.py @@ -0,0 +1,221 @@ +import pytest +import uuid +import json + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext + +def test_create_group_success(shared_zone_test_context): + """ + Tests that creating a group works + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + + try: + new_group = { + 'name': 'test-create-group-success', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(new_group, status=200) + print json.dumps(result, indent=3) + + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + assert_that(result['description'], is_(new_group['description'])) + assert_that(result['status'], is_('Active')) + assert_that(result['created'], not_none()) + assert_that(result['id'], not_none()) + assert_that(result['members'], has_length(1)) + assert_that(result['members'][0]['id'], is_('ok')) + assert_that(result['admins'], has_length(1)) + assert_that(result['admins'][0]['id'], is_('ok')) + + finally: + if result: + client.delete_group(result['id'], status=(200, 404)) + + +def test_creator_is_an_admin(shared_zone_test_context): + """ + Tests that the creator is an admin + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + + try: + new_group = { + 'name': 'test-create-group-success', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [] + } + result = client.create_group(new_group, status=200) + print json.dumps(result, indent=3) + + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + assert_that(result['description'], is_(new_group['description'])) + assert_that(result['status'], is_('Active')) + assert_that(result['created'], not_none()) + assert_that(result['id'], not_none()) + assert_that(result['members'], has_length(1)) + assert_that(result['members'][0]['id'], is_('ok')) + assert_that(result['admins'], has_length(1)) + assert_that(result['admins'][0]['id'], is_('ok')) + + finally: + if result: + client.delete_group(result['id'], status=(200, 404)) + + +def test_create_group_without_name(shared_zone_test_context): + """ + Tests that creating a group without a name fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_group = { + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + errors = client.create_group(new_group, status=400)['errors'] + assert_that(errors[0], is_("Missing Group.name")) + + +def test_create_group_without_email(shared_zone_test_context): + """ + Tests that creating a group without an email fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_group = { + 'name': 'without-email', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + errors = client.create_group(new_group, status=400)['errors'] + assert_that(errors[0], is_("Missing Group.email")) + + +def test_create_group_without_name_or_email(shared_zone_test_context): + """ + Tests that creating a group without name or an email fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_group = { + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + errors = client.create_group(new_group, status=400)['errors'] + assert_that(errors, has_length(2)) + assert_that(errors, contains_inanyorder( + "Missing Group.name", + "Missing Group.email" + )) + + +def test_create_group_without_members_or_admins(shared_zone_test_context): + """ + Tests that creating a group without members or admins fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_group = { + 'name': 'some-group-name', + 'email': 'test@test.com', + 'description': 'this is a description' + } + errors = client.create_group(new_group, status=400)['errors'] + assert_that(errors, has_length(2)) + assert_that(errors, contains_inanyorder( + "Missing Group.members", + "Missing Group.admins" + )) + + +def test_create_group_adds_admins_as_members(shared_zone_test_context): + """ + Tests that creating a group adds admins as members + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + try: + + new_group = { + 'name': 'test-create-group-add-admins-as-members', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(new_group, status=200) + + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + assert_that(result['description'], is_(new_group['description'])) + assert_that(result['status'], is_('Active')) + assert_that(result['created'], not_none()) + assert_that(result['id'], not_none()) + assert_that(result['members'][0]['id'], is_('ok')) + assert_that(result['admins'][0]['id'], is_('ok')) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + + +def test_create_group_duplicate(shared_zone_test_context): + """ + Tests that creating a group that has already been created fails + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + try: + new_group = { + 'name': 'test-create-group-duplicate', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + + result = client.create_group(new_group, status=200) + client.create_group(new_group, status=409) + + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + + +def test_create_group_no_members(shared_zone_test_context): + """ + Tests that creating a group that has no members adds current user as a member and an admin + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + + try: + new_group = { + 'name': 'test-create-group-no-members', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [], + 'admins': [] + } + + result = client.create_group(new_group, status=200) + assert_that(result['members'][0]['id'], is_('ok')) + assert_that(result['admins'][0]['id'], is_('ok')) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) diff --git a/modules/api/functional_test/live_tests/membership/delete_group_test.py b/modules/api/functional_test/live_tests/membership/delete_group_test.py new file mode 100644 index 0000000000..20d03ebdbd --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/delete_group_test.py @@ -0,0 +1,143 @@ +import pytest +import uuid +import json + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext + + +def test_delete_group_success(shared_zone_test_context): + """ + Tests that we can delete a group that has been created + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-delete-group-success', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + result = client.delete_group(saved_group['id'], status=200) + assert_that(result['status'], is_('Deleted')) + finally: + if result: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_delete_group_not_found(shared_zone_test_context): + """ + Tests that deleting a group that does not exist returns a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + client.delete_group('doesntexist', status=404) + + +def test_delete_group_that_is_already_deleted(shared_zone_test_context): + """ + Tests that deleting a group that is already deleted + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-delete-group-already', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + client.delete_group(saved_group['id'], status=200) + client.delete_group(saved_group['id'], status=404) + + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_delete_admin_group(shared_zone_test_context): + """ + Tests that we cannot delete a group that is the admin of a zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_group = None + result_zone = None + + try: + #Create group + new_group = { + 'name': 'test-delete-group-already', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + + result_group = client.create_group(new_group, status=200) + print result_group + + #Create zone with that group ID as admin + zone = { + 'name': 'one-time.', + 'email': 'test@test.com', + 'adminGroupId': result_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + client.delete_group(result_group['id'], status=400) + + #Delete zone + client.delete_zone(result_zone['id'], status=202) + client.wait_until_zone_deleted(result_zone['id']) + + #Should now be able to delete group + client.delete_group(result_group['id'], status=200) + finally: + if result_zone: + client.delete_zone(result_zone['id'], status=(202,404)) + if result_group: + client.delete_group(result_group['id'], status=(200,404)) + +def test_delete_group_not_authorized(shared_zone_test_context): + """ + Tests that only the admins can delete a zone + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + not_admin_client = shared_zone_test_context.dummy_vinyldns_client + try: + new_group = { + 'name': 'test-delete-group-not-authorized', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = ok_client.create_group(new_group, status=200) + not_admin_client.delete_group(saved_group['id'], status=403) + finally: + if saved_group: + ok_client.delete_group(saved_group['id'], status=(200,404)) diff --git a/modules/api/functional_test/live_tests/membership/get_group_changes_test.py b/modules/api/functional_test/live_tests/membership/get_group_changes_test.py new file mode 100644 index 0000000000..a9733e9fb9 --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/get_group_changes_test.py @@ -0,0 +1,209 @@ +import pytest +import datetime + +from hamcrest import * + +from vinyldns_python import VinylDNSClient + +@pytest.fixture(scope="module") +def group_activity_context(request, shared_zone_test_context): + client = shared_zone_test_context.ok_vinyldns_client + created_group = None + + group_name = 'test-list-group-activity-max-item-success' + + # cleanup existing group if it's already in there + groups = client.list_all_my_groups() + existing = [grp for grp in groups if grp['name'] == group_name] + for grp in existing: + client.delete_group(grp['id'], status=200) + + + members = [ { 'id': 'ok'} ] + new_group = { + 'name': group_name, + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + created_group = client.create_group(new_group, status=200) + + update_groups = [] + updated_groups = [] + # each update changes the member + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members = [{ 'id': id }] + update_groups.append({ + 'id': created_group['id'], + 'name': group_name, + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + }) + updated_groups.append(client.update_group(update_groups[runner]['id'], update_groups[runner], status=200)) + + def fin(): + if created_group: + client.delete_group(created_group['id'], status=(200,404)) + + request.addfinalizer(fin) + + return { + 'created_group': created_group, + 'updated_groups': updated_groups + } + + +def test_list_group_activity_start_from_success(group_activity_context, shared_zone_test_context): + """ + Test that we can list the changes starting from a given timestamp + """ + + client = shared_zone_test_context.ok_vinyldns_client + created_group = group_activity_context['created_group'] + updated_groups = group_activity_context['updated_groups'] + + page_one = client.get_group_changes(created_group['id'], status=200) + + start_from_index = 50 + start_from = page_one['changes'][start_from_index]['created'] # start from a known good timestamp + + result = client.get_group_changes(created_group['id'], start_from=start_from, status=200) + + assert_that(result['changes'], has_length(100)) + assert_that(result['maxItems'], is_(100)) + assert_that(result['startFrom'], is_(start_from)) + assert_that(result['nextId'], is_not(none())) + + for i in range(0,100): + assert_that(result['changes'][i]['newGroup'], is_(updated_groups[199-start_from_index-i-1])) + assert_that(result['changes'][i]['oldGroup'], is_(updated_groups[199-start_from_index-i-2])) + +def test_list_group_activity_start_from_fake_time(group_activity_context, shared_zone_test_context): + """ + Test that we can start from a fake time stamp + """ + + client = shared_zone_test_context.ok_vinyldns_client + created_group = group_activity_context['created_group'] + updated_groups = group_activity_context['updated_groups'] + start_from = '9999999999999' # start from a random timestamp far in the future + + result = client.get_group_changes(created_group['id'], start_from=start_from, status=200) + + # there are 200 updates, and 1 create + assert_that(result['changes'], has_length(100)) + assert_that(result['maxItems'], is_(100)) + assert_that(result['startFrom'], is_(start_from)) + assert_that(result['nextId'], is_not(none())) + + for i in range(0,100): + assert_that(result['changes'][i]['newGroup'], is_(updated_groups[199-i])) + assert_that(result['changes'][i]['oldGroup'], is_(updated_groups[199-i-1])) + + +def test_list_group_activity_max_item_success(group_activity_context, shared_zone_test_context): + """ + Test that we can set the max_items returned + """ + + client = shared_zone_test_context.ok_vinyldns_client + created_group = group_activity_context['created_group'] + updated_groups = group_activity_context['updated_groups'] + + result = client.get_group_changes(created_group['id'], max_items=50, status=200) + + # there are 200 updates, and 1 create + assert_that(result['changes'], has_length(50)) + assert_that(result['maxItems'], is_(50)) + assert_that(result, is_not(has_key('startFrom'))) + assert_that(result['nextId'], is_not(none())) + + for i in range(0,50): + assert_that(result['changes'][i]['newGroup'], is_(updated_groups[199-i])) + assert_that(result['changes'][i]['oldGroup'], is_(updated_groups[199-i-1])) + + +def test_list_group_activity_max_item_zero(group_activity_context, shared_zone_test_context): + """ + Test that max_item set to zero fails + """ + + client = shared_zone_test_context.ok_vinyldns_client + created_group = group_activity_context['created_group'] + client.get_group_changes(created_group['id'], max_items=0, status=400) + + +def test_list_group_activity_max_item_over_1000(group_activity_context, shared_zone_test_context): + """ + Test that when max_item is over 1000 fails + """ + + client = shared_zone_test_context.ok_vinyldns_client + created_group = group_activity_context['created_group'] + client.get_group_changes(created_group['id'], max_items=1001, status=400) + + +def test_get_group_changes_paging(group_activity_context, shared_zone_test_context): + """ + Test that we can page through multiple pages of group changes + """ + + client = shared_zone_test_context.ok_vinyldns_client + created_group = group_activity_context['created_group'] + updated_groups = group_activity_context['updated_groups'] + + page_one = client.get_group_changes(created_group['id'], max_items=100, status=200) + page_two = client.get_group_changes(created_group['id'], start_from=page_one['nextId'], max_items=100, status=200) + page_three = client.get_group_changes(created_group['id'], start_from=page_two['nextId'], max_items=100, status=200) + + assert_that(page_one['changes'], has_length(100)) + assert_that(page_one['maxItems'], is_(100)) + assert_that(page_one, is_not(has_key('startFrom'))) + assert_that(page_one['nextId'], is_not(none())) + + for i in range(0, 100): + assert_that(page_one['changes'][i]['newGroup'], is_(updated_groups[199-i])) + assert_that(page_one['changes'][i]['oldGroup'], is_(updated_groups[199-i-1])) + + assert_that(page_two['changes'], has_length(100)) + assert_that(page_two['maxItems'], is_(100)) + assert_that(page_two['startFrom'], is_(page_one['nextId'])) + assert_that(page_two['nextId'], is_not(none())) + + for i in range(100, 199): + assert_that(page_two['changes'][i-100]['newGroup'], is_(updated_groups[199-i])) + assert_that(page_two['changes'][i-100]['oldGroup'], is_(updated_groups[199-i-1])) + assert_that(page_two['changes'][99]['oldGroup'], is_(created_group)) + + assert_that(page_three['changes'], has_length(1)) + assert_that(page_three['maxItems'], is_(100)) + assert_that(page_three['startFrom'], is_(page_two['nextId'])) + assert_that(page_three, is_not(has_key('nextId'))) + + assert_that(page_three['changes'][0]['newGroup'], is_(created_group)) + +def test_get_group_changes_unauthed(shared_zone_test_context): + """ + Tests that we cant get group changes without access + """ + + client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-list-group-admins-unauthed', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + dummy_client.get_group_changes(saved_group['id'], status=403) + client.get_group_changes(saved_group['id'], status=200) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + diff --git a/modules/api/functional_test/live_tests/membership/get_group_test.py b/modules/api/functional_test/live_tests/membership/get_group_test.py new file mode 100644 index 0000000000..8d51b90730 --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/get_group_test.py @@ -0,0 +1,93 @@ +import pytest +import json + +from hamcrest import * +from vinyldns_python import VinylDNSClient + + +def test_get_group_success(shared_zone_test_context): + """ + Tests that we can get a group that has been created + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-get-group-success', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + group = client.get_group(saved_group['id'], status=200) + + assert_that(group['name'], is_(saved_group['name'])) + assert_that(group['email'], is_(saved_group['email'])) + assert_that(group['description'], is_(saved_group['description'])) + assert_that(group['status'], is_(saved_group['status'])) + assert_that(group['created'], is_(saved_group['created'])) + assert_that(group['id'], is_(saved_group['id'])) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_get_group_not_found(shared_zone_test_context): + """ + Tests that getting a group that does not exist returns a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + client.get_group('doesntexist', status=404) + + +def test_get_deleted_group(shared_zone_test_context): + """ + Tests getting a group that was already deleted + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-get-deleted-group', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + client.delete_group(saved_group['id'], status=200) + client.get_group(saved_group['id'], status=404) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_get_group_unauthed(shared_zone_test_context): + """ + Tests that we cant get a group were not in + """ + + client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-get-group-unauthed', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + dummy_client.get_group(saved_group['id'], status=403) + client.get_group(saved_group['id'], status=200) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) diff --git a/modules/api/functional_test/live_tests/membership/list_group_admins_test.py b/modules/api/functional_test/live_tests/membership/list_group_admins_test.py new file mode 100644 index 0000000000..389743f09e --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/list_group_admins_test.py @@ -0,0 +1,81 @@ + +import pytest +import json + +from hamcrest import * + +from vinyldns_python import VinylDNSClient + + +def test_list_group_admins_success(shared_zone_test_context): + """ + Test that we can list all the admins of a given group + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-list-group-admins-success', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'}, { 'id': 'dummy'} ] + } + saved_group = client.create_group(new_group, status=200) + + admin_user_1_id = 'ok' + admin_user_2_id = 'dummy' + + result = client.get_group(saved_group['id'], status=200) + + assert_that(result['admins'], has_length(2)) + assert_that([admin_user_1_id, admin_user_2_id], has_item(result['admins'][0]['id'])) + assert_that([admin_user_1_id, admin_user_2_id], has_item(result['admins'][1]['id'])) + + result = client.list_group_admins(saved_group['id'], status=200) + print json.dumps(result, indent=3) + + result = sorted(result['admins'], key=lambda user: user['userName']) + assert_that(result, has_length(2)) + assert_that(result[0]['userName'], is_('dummy')) + assert_that(result[0]['id'], is_('dummy')) + assert_that(result[0]['created'], not_none()) + assert_that(result[1]['userName'], is_('ok')) + assert_that(result[1]['id'], is_('ok')) + assert_that(result[1]['created'], not_none()) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_admins_group_not_found(shared_zone_test_context): + """ + Test that listing the admins of a non-existent group fails + """ + + client = shared_zone_test_context.ok_vinyldns_client + client.list_group_admins('doesntexist', status=404) + + +def test_list_group_admins_unauthed(shared_zone_test_context): + """ + Tests that we cant list admins without access + """ + + client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-list-group-admins-unauthed', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + dummy_client.list_group_admins(saved_group['id'], status=403) + client.list_group_admins(saved_group['id'], status=200) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) diff --git a/modules/api/functional_test/live_tests/membership/list_group_members_test.py b/modules/api/functional_test/live_tests/membership/list_group_members_test.py new file mode 100644 index 0000000000..bb5cce43ed --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/list_group_members_test.py @@ -0,0 +1,567 @@ +import pytest +import json + +from hamcrest import * + +from vinyldns_python import VinylDNSClient + + +def test_list_group_members_success(shared_zone_test_context): + """ + Test that we can list all the members of a group + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-list-group-members-success', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, { 'id': 'dummy' } ], + 'admins': [ { 'id': 'ok'} ] + } + + members = sorted(['dummy', 'ok']) + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + assert_that(result['members'], has_length(len(members))) + + result_member_ids = map(lambda member: member['id'], result['members']) + for id in members: + assert_that(result_member_ids, has_item(id)) + + result = client.list_members_group(saved_group['id'], status=200) + result = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result, has_length(len(members))) + dummy = result[0] + assert_that(dummy['id'], is_('dummy')) + assert_that(dummy['userName'], is_('dummy')) + assert_that(dummy['isAdmin'], is_(False)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + + ok = result[1] + assert_that(ok['id'], is_('ok')) + assert_that(ok['userName'], is_('ok')) + assert_that(ok['isAdmin'], is_(True)) + assert_that(ok['firstName'], is_('ok')) + assert_that(ok['lastName'], is_('ok')) + assert_that(ok['email'], is_('test@test.com')) + assert_that(ok['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_not_found(shared_zone_test_context): + """ + Tests that we can not list the members of a non-existent group + """ + + client = shared_zone_test_context.ok_vinyldns_client + + client.list_members_group('not_found', status=404) + + +def test_list_group_members_start_from(shared_zone_test_context): + """ + Test that we can list the members starting from a given user + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-start-from', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + assert_that(result['members'], has_item({ 'id': 'ok'})) + result_member_ids = map(lambda member: member['id'], result['members']) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + result = client.list_members_group(saved_group['id'], start_from='dummy050', status=200) + + group_members = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result['startFrom'], is_('dummy050')) + assert_that(result['nextId'], is_('dummy150')) + + assert_that(group_members, has_length(100)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i+51) #starts from dummy051 + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy['isAdmin'], is_(False)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_start_from_non_user(shared_zone_test_context): + """ + Test that we can list the members starting from a non existent username + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-start-from-nonexistent', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + result = client.list_members_group(saved_group['id'], start_from='abc', status=200) + + group_members = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result['startFrom'], is_('abc')) + assert_that(result['nextId'], is_('dummy099')) + + assert_that(group_members, has_length(100)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy['isAdmin'], is_(False)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_max_item(shared_zone_test_context): + """ + Test that we can chose the number of items to list + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-max-items', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + result = client.list_members_group(saved_group['id'], max_items=10, status=200) + + group_members = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result['nextId'], is_('dummy009')) + assert_that(result['maxItems'], is_(10)) + + assert_that(group_members, has_length(10)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy['isAdmin'], is_(False)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_max_item_default(shared_zone_test_context): + """ + Test that the default for max_item is 100 items + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-max-items-default', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + result = client.list_members_group(saved_group['id'], status=200) + + group_members = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result['nextId'], is_('dummy099')) + + assert_that(group_members, has_length(100)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy['isAdmin'], is_(False)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_max_item_zero(shared_zone_test_context): + """ + Test that the call fails when max_item is 0 + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-max-items-zero', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + client.list_members_group(saved_group['id'], max_items=0, status=400) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_max_item_over_1000(shared_zone_test_context): + """ + Test that the call fails when max_item is over 1000 + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-max-items-over-limit', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + client.list_members_group(saved_group['id'], max_items=1001, status=400) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_next_id_correct(shared_zone_test_context): + """ + Test that the correct next_id is returned + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 200): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-next-id', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + result = client.list_members_group(saved_group['id'], status=200) + + group_members = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result['nextId'], is_('dummy099')) + + assert_that(group_members, has_length(100)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy['isAdmin'], is_(False)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_next_id_exhausted(shared_zone_test_context): + """ + Test that the next_id is null when the list is exhausted + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 5): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-next-id-exhausted', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + result = client.list_members_group(saved_group['id'], status=200) + + group_members = sorted(result['members'], key=lambda user: user['id']) + + assert_that(result, is_not(has_key('nextId'))) + + assert_that(group_members, has_length(6)) # add one more for the admin + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_next_id_exhausted_two_pages(shared_zone_test_context): + """ + Test that the next_id is null when the list is exhausted over 2 pages + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + members = [] + for runner in range(0, 19): + id = "dummy{0:0>3}".format(runner) + members.append({ 'id': id }) + members = sorted(members) + + new_group = { + 'name': 'test-list-group-members-next-id-exhausted-two-pages', + 'email': 'test@test.com', + 'members': members, + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + + # members has one more because admins are added as members + assert_that(result['members'], has_length(len(members) + 1)) + result_member_ids = map(lambda member: member['id'], result['members']) + assert_that(result_member_ids, has_item('ok')) + for user in members: + assert_that(result_member_ids, has_item(user['id'])) + + first_page = client.list_members_group(saved_group['id'], max_items=10, status=200) + + group_members = sorted(first_page['members'], key=lambda user: user['id']) + + assert_that(first_page['nextId'], is_('dummy009')) + assert_that(first_page['maxItems'], is_(10)) + + assert_that(group_members, has_length(10)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + + second_page = client.list_members_group(saved_group['id'], + start_from=first_page['nextId'], + max_items=10, + status=200) + + group_members = sorted(second_page['members'], key=lambda user: user['id']) + + assert_that(second_page, is_not(has_key('nextId'))) + assert_that(second_page['maxItems'], is_(10)) + + assert_that(group_members, has_length(10)) + for i in range(0, len(group_members)-1): + dummy = group_members[i] + id = "dummy{0:0>3}".format(i+10) + user_name = "name-"+id + assert_that(dummy['id'], is_(id)) + assert_that(dummy['userName'], is_(user_name)) + assert_that(dummy, is_not(has_key('firstName'))) + assert_that(dummy, is_not(has_key('lastName'))) + assert_that(dummy, is_not(has_key('email'))) + assert_that(dummy['created'], is_not(none())) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_list_group_members_unauthed(shared_zone_test_context): + """ + Tests that we cant list members without access + """ + + client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-list-group-members-unauthed', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + dummy_client.list_members_group(saved_group['id'], status=403) + client.list_members_group(saved_group['id'], status=200) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) diff --git a/modules/api/functional_test/live_tests/membership/list_my_groups_test.py b/modules/api/functional_test/live_tests/membership/list_my_groups_test.py new file mode 100644 index 0000000000..4ef0b018aa --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/list_my_groups_test.py @@ -0,0 +1,165 @@ +import pytest +import json + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from utils import * +from vinyldns_context import VinylDNSTestContext + +class ListGroupsSearchContext(object): + def __init__(self): + self.client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, access_key='listGroupAccessKey', secret_key='listGroupSecretKey') + self.tear_down() # ensures that the environment is clean before starting + + try: + for runner in range(0, 50): + new_group = { + 'name': "test-list-my-groups-{0:0>3}".format(runner), + 'email': 'test@test.com', + 'members': [ { 'id': 'list-group-user'} ], + 'admins': [ { 'id': 'list-group-user'} ] + } + self.client.create_group(new_group, status=200) + + except: + # teardown if there was any issue in setup + try: + self.tear_down() + except: + pass + raise + + def tear_down(self): + clear_zones(self.client) + clear_groups(self.client) + +@pytest.fixture(scope="module") +def list_my_groups_context(request): + ctx = ListGroupsSearchContext() + + def fin(): + ctx.tear_down() + + request.addfinalizer(fin) + + return ctx + +def test_list_my_groups_no_parameters(list_my_groups_context): + """ + Test that we can get all the groups where a user is a member + """ + + results = list_my_groups_context.client.list_my_groups(status=200) + + assert_that(results, has_length(2)) # 2 fields + + assert_that(results['groups'], has_length(50)) + assert_that(results, is_not(has_key('groupNameFilter'))) + assert_that(results, is_not(has_key('startFrom'))) + assert_that(results, is_not(has_key('nextId'))) + assert_that(results['maxItems'], is_(100)) + + results['groups'] = sorted(results['groups'], key=lambda x: x['name']) + + for i in range(0, 50): + assert_that(results['groups'][i]['name'], is_("test-list-my-groups-{0:0>3}".format(i))) + + +def test_get_my_groups_using_old_account_auth(list_my_groups_context): + """ + Test passing in an account will return an empty set + """ + results = list_my_groups_context.client.list_my_groups(status=200) + assert_that(results, has_length(2)) + assert_that(results, is_not(has_key('groupNameFilter'))) + assert_that(results, is_not(has_key('startFrom'))) + assert_that(results, is_not(has_key('nextId'))) + assert_that(results['maxItems'], is_(100)) + + +def test_list_my_groups_max_items(list_my_groups_context): + """ + Tests that when maxItem is set, only return #maxItems items + """ + results = list_my_groups_context.client.list_my_groups(max_items=5, status=200) + + assert_that(results, has_length(3)) # 3 fields + + assert_that(results, has_key('groups')) + assert_that(results, is_not(has_key('groupNameFilter'))) + assert_that(results, is_not(has_key('startFrom'))) + assert_that(results, has_key('nextId')) + assert_that(results['maxItems'], is_(5)) + + +def test_list_my_groups_paging(list_my_groups_context): + """ + Tests that we can return all items by paging + """ + results=list_my_groups_context.client.list_my_groups(max_items=20, status=200) + + assert_that(results, has_length(3)) # 3 fields + assert_that(results, has_key('groups')) + assert_that(results, is_not(has_key('groupNameFilter'))) + assert_that(results, is_not(has_key('startFrom'))) + assert_that(results, has_key('nextId')) + assert_that(results['maxItems'], is_(20)) + + while 'nextId' in results: + prev = results + results = list_my_groups_context.client.list_my_groups(max_items=20, start_from=results['nextId'], status=200) + + if 'nextId' in results: + assert_that(results, has_length(4)) # 4 fields + assert_that(results, has_key('groups')) + assert_that(results, is_not(has_key('groupNameFilter'))) + assert_that(results['startFrom'], is_(prev['nextId'])) + assert_that(results, has_key('nextId')) + assert_that(results['maxItems'], is_(20)) + + else: + assert_that(results, has_length(3)) # 3 fields + assert_that(results, has_key('groups')) + assert_that(results, is_not(has_key('groupNameFilter'))) + assert_that(results['startFrom'], is_(prev['nextId'])) + assert_that(results, is_not(has_key('nextId'))) + assert_that(results['maxItems'], is_(20)) + + +def test_list_my_groups_filter_matches(list_my_groups_context): + """ + Tests that only matched groups are returned + """ + results = list_my_groups_context.client.list_my_groups(group_name_filter="test-list-my-groups-01", status=200) + + assert_that(results, has_length(3)) # 3 fields + + assert_that(results['groups'], has_length(10)) + assert_that(results['groupNameFilter'], is_('test-list-my-groups-01')) + assert_that(results, is_not(has_key('startFrom'))) + assert_that(results, is_not(has_key('nextId'))) + assert_that(results['maxItems'], is_(100)) + + results['groups'] = sorted(results['groups'], key=lambda x: x['name']) + + for i in range(0, 10): + assert_that(results['groups'][i]['name'], is_("test-list-my-groups-{0:0>3}".format(i+10))) + + +def test_list_my_groups_no_deleted(list_my_groups_context): + """ + Tests that no deleted groups are returned + """ + results=list_my_groups_context.client.list_my_groups(max_items=100, status=200) + + assert_that(results, has_key('groups')) + for g in results['groups']: + assert_that(g['status'], is_not('Deleted')) + + while 'nextId' in results: + results = client.list_my_groups(max_items=20, group_name_filter="test-list-my-groups-", start_from=results['nextId'], status=200) + + assert_that(results, has_key('groups')) + for g in results['groups']: + assert_that(g['status'], is_not('Deleted')) + diff --git a/modules/api/functional_test/live_tests/membership/update_group_test.py b/modules/api/functional_test/live_tests/membership/update_group_test.py new file mode 100644 index 0000000000..189d49d989 --- /dev/null +++ b/modules/api/functional_test/live_tests/membership/update_group_test.py @@ -0,0 +1,616 @@ +import pytest +import json +import time + +from hamcrest import * +from vinyldns_python import VinylDNSClient + + +def test_update_group_success(shared_zone_test_context): + """ + Tests that we can update a group that has been created + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-update-group-success', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + group = client.get_group(saved_group['id'], status=200) + + assert_that(group['name'], is_(saved_group['name'])) + assert_that(group['email'], is_(saved_group['email'])) + assert_that(group['description'], is_(saved_group['description'])) + assert_that(group['status'], is_(saved_group['status'])) + assert_that(group['created'], is_(saved_group['created'])) + assert_that(group['id'], is_(saved_group['id'])) + + time.sleep(1) # sleep to ensure that update doesnt change created time + + update_group = { + 'id': group['id'], + 'name': 'updated-name', + 'email': 'update@test.com', + 'description': 'this is a new description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + group = client.update_group(update_group['id'], update_group, status=200) + + assert_that(group['name'], is_(update_group['name'])) + assert_that(group['email'], is_(update_group['email'])) + assert_that(group['description'], is_(update_group['description'])) + assert_that(group['status'], is_(saved_group['status'])) + assert_that(group['created'], is_(saved_group['created'])) + assert_that(group['id'], is_(saved_group['id'])) + assert_that(group['members'][0]['id'], is_('ok')) + assert_that(group['admins'][0]['id'], is_('ok')) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_update_group_without_name(shared_zone_test_context): + """ + Tests that updating a group without a name fails + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + try: + new_group = { + 'name': 'test-update-without-name', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(new_group, status=200) + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + + update_group = { + 'id': result['id'], + 'email': 'update@test.com', + 'description': 'this is a new description' + } + + errors = client.update_group(update_group['id'], update_group, status=400)['errors'] + assert_that(errors[0], is_("Missing Group.name")) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + + +def test_update_group_without_email(shared_zone_test_context): + """ + Tests that updating a group without an email fails + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + try: + new_group = { + 'name': 'test-update-without-email', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(new_group, status=200) + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + + update_group = { + 'id': result['id'], + 'name': 'without-email', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + errors = client.update_group(update_group['id'], update_group, status=400)['errors'] + assert_that(errors[0], is_("Missing Group.email")) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + + +def test_updating_group_without_name_or_email(shared_zone_test_context): + """ + Tests that updating a group without name or an email fails + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + try: + new_group = { + 'name': 'test-update-without-name-and-email', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(new_group, status=200) + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + + update_group = { + 'id': result['id'], + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + errors = client.update_group(update_group['id'], update_group, status=400)['errors'] + assert_that(errors, has_length(2)) + assert_that(errors, contains_inanyorder( + "Missing Group.name", + "Missing Group.email" + )) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + + +def test_updating_group_without_members_or_admins(shared_zone_test_context): + """ + Tests that updating a group without members or admins fails + """ + client = shared_zone_test_context.ok_vinyldns_client + result = None + + try: + new_group = { + 'name': 'test-update-without-members', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(new_group, status=200) + assert_that(result['name'], is_(new_group['name'])) + assert_that(result['email'], is_(new_group['email'])) + + update_group = { + 'id': result['id'], + 'name': 'test-update-without-members', + 'email': 'test@test.com', + 'description': 'this is a description', + } + errors = client.update_group(update_group['id'], update_group, status=400)['errors'] + assert_that(errors, has_length(2)) + assert_that(errors, contains_inanyorder( + "Missing Group.members", + "Missing Group.admins" + )) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + + +def test_update_group_adds_admins_as_members(shared_zone_test_context): + """ + Tests that when we add an admin to a group the admin is also a member + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-update-group-admins-as-members', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + group = client.get_group(saved_group['id'], status=200) + + assert_that(group['name'], is_(saved_group['name'])) + assert_that(group['email'], is_(saved_group['email'])) + assert_that(group['description'], is_(saved_group['description'])) + assert_that(group['status'], is_(saved_group['status'])) + assert_that(group['created'], is_(saved_group['created'])) + assert_that(group['id'], is_(saved_group['id'])) + + update_group = { + 'id': group['id'], + 'name': 'test-update-group-admins-as-members', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'}, { 'id': 'dummy' } ] + } + group = client.update_group(update_group['id'], update_group, status=200) + + import json + print json.dumps(group, indent=4) + + assert_that(group['members'], has_length(2)) + assert_that(['ok', 'dummy'], has_item(group['members'][0]['id'])) + assert_that(['ok', 'dummy'], has_item(group['members'][1]['id'])) + assert_that(group['admins'], has_length(2)) + assert_that(['ok', 'dummy'], has_item(group['admins'][0]['id'])) + assert_that(['ok', 'dummy'], has_item(group['admins'][1]['id'])) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_update_group_conflict(shared_zone_test_context): + """ + Tests that we can not update a groups name to a name already in use + """ + + client = shared_zone_test_context.ok_vinyldns_client + result = None + conflict_group=None + try: + new_group = { + 'name': 'test_update_group_conflict', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + conflict_group = client.create_group(new_group, status=200) + assert_that(conflict_group['name'], is_(new_group['name'])) + + other_group = { + 'name': 'change_me', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + result = client.create_group(other_group, status=200) + assert_that(result['name'], is_(other_group['name'])) + + # change the name of the other_group to the first group (conflict) + update_group = { + 'id': result['id'], + 'name': 'test_update_group_conflict', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + client.update_group(update_group['id'], update_group, status=409) + finally: + if result: + client.delete_group(result['id'], status=(200,404)) + if conflict_group: + client.delete_group(conflict_group['id'], status=(200,404)) + + +def test_update_group_not_found(shared_zone_test_context): + """ + Tests that we can not update a group that has not been created + """ + + client = shared_zone_test_context.ok_vinyldns_client + + update_group = { + 'id': 'test-update-group-not-found', + 'name': 'test-update-group-not-found', + 'email': 'update@test.com', + 'description': 'this is a new description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + client.update_group(update_group['id'], update_group, status=404) + + +def test_update_group_deleted(shared_zone_test_context): + """ + Tests that we can not update a group that has been deleted + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-update-group-deleted', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + client.delete_group(saved_group['id'], status=200) + + update_group = { + 'id': saved_group['id'], + 'name': 'test-update-group-deleted-updated', + 'email': 'update@test.com', + 'description': 'this is a new description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + client.update_group(update_group['id'], update_group, status=404) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_add_member_via_update_group_success(shared_zone_test_context): + """ + Tests that we can add a member to a group via update successfully + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-add-member-to-via-update-group-success', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-add-member-to-via-update-group-success', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, { 'id': 'dummy' } ], + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + expected_members = ['ok', 'dummy'] + assert_that(saved_group['members'], has_length(2)) + assert_that(expected_members, has_item(saved_group['members'][0]['id'])) + assert_that(expected_members, has_item(saved_group['members'][1]['id'])) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_add_member_to_group_twice_via_update_group(shared_zone_test_context): + """ + Tests that we can add a member to a group twice successfully via update group + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + try: + new_group = { + 'name': 'test-add-member-to-group-twice-success-via-update-group', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-add-member-to-group-twice-success-via-update-group', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, { 'id': 'dummy' } ], + 'admins': [ { 'id': 'ok'} ] + } + + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + expected_members = ['ok', 'dummy'] + assert_that(saved_group['members'], has_length(2)) + assert_that(expected_members, has_item(saved_group['members'][0]['id'])) + assert_that(expected_members, has_item(saved_group['members'][1]['id'])) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_add_not_found_member_to_group_via_update_group(shared_zone_test_context): + """ + Tests that we can not add a non-existent member to a group via update group + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-add-not-found-member-to-group-via-update-group', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + result = client.get_group(saved_group['id'], status=200) + assert_that(result['members'], has_length(1)) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-add-not-found-member-to-group-via-update-group', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, { 'id': 'not_found' } ], + 'admins': [ { 'id': 'ok'} ] + } + + client.update_group(updated_group['id'], updated_group, status=404) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_remove_member_via_update_group_success(shared_zone_test_context): + """ + Tests that we can remove a member via update group successfully + """ + + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-remove-member-via-update-group-success', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, {'id': 'dummy'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.create_group(new_group, status=200) + assert_that(saved_group['members'], has_length(2)) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-remove-member-via-update-group-success', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + + assert_that(saved_group['members'], has_length(1)) + assert_that(saved_group['members'][0]['id'], is_('ok')) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_remove_member_and_admin(shared_zone_test_context): + """ + Tests that if we remove a member who is an admin, the admin is also removed + """ + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-remove-member-and-admin', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, {'id': 'dummy'} ], + 'admins': [ { 'id': 'ok'}, {'id': 'dummy'} ] + } + saved_group = client.create_group(new_group, status=200) + assert_that(saved_group['members'], has_length(2)) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-remove-member-and-admin', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + + assert_that(saved_group['members'], has_length(1)) + assert_that(saved_group['members'][0]['id'], is_('ok')) + assert_that(saved_group['admins'], has_length(1)) + assert_that(saved_group['admins'][0]['id'], is_('ok')) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_remove_member_but_not_admin_keeps_member(shared_zone_test_context): + """ + Tests that if we remove a member but do not remove the admin, the admin remains a member + """ + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-remove-member-not-admin-keeps-member', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, {'id': 'dummy'} ], + 'admins': [ { 'id': 'ok'}, {'id': 'dummy'} ] + } + saved_group = client.create_group(new_group, status=200) + assert_that(saved_group['members'], has_length(2)) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-remove-member-not-admin-keeps-member', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'}, {'id': 'dummy'} ] + } + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + + expected_members = ['ok', 'dummy'] + assert_that(saved_group['members'], has_length(2)) + assert_that(expected_members, has_item(saved_group['members'][0]['id'])) + assert_that(expected_members, has_item(saved_group['members'][1]['id'])) + assert_that(expected_members, has_item(saved_group['admins'][0]['id'])) + assert_that(expected_members, has_item(saved_group['admins'][1]['id'])) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_remove_admin_keeps_member(shared_zone_test_context): + """ + Tests that if we remove a member from admins, the member still remains part of the group + """ + client = shared_zone_test_context.ok_vinyldns_client + saved_group = None + + try: + new_group = { + 'name': 'test-remove-admin-keeps-member', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, {'id': 'dummy'} ], + 'admins': [ { 'id': 'ok'}, {'id': 'dummy'} ] + } + saved_group = client.create_group(new_group, status=200) + assert_that(saved_group['members'], has_length(2)) + + updated_group = { + 'id': saved_group['id'], + 'name': 'test-remove-admin-keeps-member', + 'email': 'test@test.com', + 'members': [ { 'id': 'ok'}, {'id': 'dummy'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = client.update_group(updated_group['id'], updated_group, status=200) + + expected_members = ['ok', 'dummy'] + assert_that(saved_group['members'], has_length(2)) + assert_that(expected_members, has_item(saved_group['members'][0]['id'])) + assert_that(expected_members, has_item(saved_group['members'][1]['id'])) + + assert_that(saved_group['admins'], has_length(1)) + assert_that(saved_group['admins'][0]['id'], is_('ok')) + finally: + if saved_group: + client.delete_group(saved_group['id'], status=(200,404)) + + +def test_update_group_not_authorized(shared_zone_test_context): + """ + Tests that only the admins can update a zone + """ + ok_client = shared_zone_test_context.ok_vinyldns_client + not_admin_client = shared_zone_test_context.dummy_vinyldns_client + try: + new_group = { + 'name': 'test-update-group-not-authorized', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + saved_group = ok_client.create_group(new_group, status=200) + + update_group = { + 'id': saved_group['id'], + 'name': 'updated-name', + 'email': 'update@test.com', + 'description': 'this is a new description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + not_admin_client.update_group(update_group['id'], update_group, status=403) + finally: + if saved_group: + ok_client.delete_group(saved_group['id'], status=(200,404)) diff --git a/modules/api/functional_test/live_tests/production_verify_test.py b/modules/api/functional_test/live_tests/production_verify_test.py new file mode 100644 index 0000000000..87cd57a647 --- /dev/null +++ b/modules/api/functional_test/live_tests/production_verify_test.py @@ -0,0 +1,70 @@ +import pytest +import sys +import dns.query +import dns.tsigkeyring +import dns.update + +from utils import * +from hamcrest import * +from vinyldns_python import VinylDNSClient +from test_data import TestData +from dns.resolver import * + + +def test_verify_production(shared_zone_test_context): + """ + Test that production works. This test sets up the shared context, which creates a lot of groups and zones + and then really just creates a single recordset and delete it. + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_create_recordset_with_dns_verify', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(2)) + assert_that('10.1.1.1', is_in(records)) + assert_that('10.2.2.2', is_in(records)) + + print "\r\n\r\n!!!verifying recordset in dns backend" + answers = dns_resolve(shared_zone_test_context.ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + assert_that(answers, has_length(2)) + assert_that('10.1.1.1', is_in(rdata_strings)) + assert_that('10.2.2.2', is_in(rdata_strings)) + finally: + if result_rs: + try: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_deleted(delete_result['zoneId'], delete_result['id']) + except: + pass \ No newline at end of file diff --git a/modules/api/functional_test/live_tests/recordsets/create_recordset_test.py b/modules/api/functional_test/live_tests/recordsets/create_recordset_test.py new file mode 100644 index 0000000000..e5dedd9ff0 --- /dev/null +++ b/modules/api/functional_test/live_tests/recordsets/create_recordset_test.py @@ -0,0 +1,1646 @@ +import pytest +from utils import * +from hamcrest import * +from vinyldns_python import VinylDNSClient +from test_data import TestData +from dns.resolver import * + + +def test_create_recordset_with_dns_verify(shared_zone_test_context): + """ + Test creating a new record set in an existing zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_create_recordset_with_dns_verify', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(2)) + assert_that('10.1.1.1', is_in(records)) + assert_that('10.2.2.2', is_in(records)) + + print "\r\n\r\n!!!verifying recordset in dns backend" + answers = dns_resolve(shared_zone_test_context.ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + assert_that(answers, has_length(2)) + assert_that('10.1.1.1', is_in(rdata_strings)) + assert_that('10.2.2.2', is_in(rdata_strings)) + finally: + if result_rs: + try: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass + + +def test_create_srv_recordset_with_service_and_protocol(shared_zone_test_context): + """ + Test creating a new srv record set with service and protocol works + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': '_sip._tcp._test-create-srv-ok', + 'type': 'SRV', + 'ttl': 100, + 'records': [ + { + 'priority': 1, + 'weight': 2, + 'port': 8000, + 'target': 'srv.' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_srv_recordset_with_service_and_protocol(shared_zone_test_context): + """ + Test creating a new srv record set with service and protocol works + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': '_sip._tcp._test-create-srv-ok', + 'type': 'SRV', + 'ttl': 100, + 'records': [ + { + 'priority': 1, + 'weight': 2, + 'port': 8000, + 'target': 'srv.' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_aaaa_recordset_with_shorthand_record(shared_zone_test_context): + """ + Test creating an AAAA record using shorthand for record data works + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'testAAAA', + 'type': 'AAAA', + 'ttl': 100, + 'records': [ + { + 'address': '1::2' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_aaaa_recordset_with_normal_record(shared_zone_test_context): + """ + Test creating an AAAA record not using shorthand for record data works + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'testAAAA', + 'type': 'AAAA', + 'ttl': 100, + 'records': [ + { + 'address': '1:2:3:4:5:6:7:8' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_recordset_conflict(shared_zone_test_context): + """ + Test creating a record set with the same name and type of an existing one returns a 409 + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_create_recordset_conflict', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result = None + result_rs = None + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + client.create_recordset(result_rs, status=409) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_recordset_conflict_with_case_insensitive_name(shared_zone_test_context): + """ + Test creating a record set with the same name, but different casing, and type of an existing one returns a 409 + """ + client = shared_zone_test_context.ok_vinyldns_client + first_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_create_recordset_conflict', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result = None + result_rs = None + + try: + result = client.create_recordset(first_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + first_rs['name'] = 'test_create_recordset_CONFLICT' + client.create_recordset(first_rs, status=409) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_recordset_conflict_with_trailing_dot_insensitive_name(shared_zone_test_context): + """ + Test creating a record set with the same name (but without a trailing dot) and type of an existing one returns a 409 + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + first_rs = { + 'zoneId': zone['id'], + 'name': 'parent.com.', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result_rs = None + try: + result = client.create_recordset(first_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + first_rs['name'] = 'parent.com' + client.create_recordset(first_rs, status=409) + + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_recordset_conflict_with_dns(shared_zone_test_context): + """ + Test creating a duplicate record set with the same name and same type of an existing one in DNS fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'backend-conflict', + 'type': 'A', + 'ttl': 38400, + 'records': [ + { + 'address': '7.7.7.7' #records with different data should fail, these live in the dns hosts + } + ] + } + + try: + dns_add(shared_zone_test_context.ok_zone, "backend-conflict", 200, "A", "1.2.3.4") + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print json.dumps(result, indent=3) + client.wait_until_recordset_change_status(result, 'Failed') + + finally: + dns_delete(shared_zone_test_context.ok_zone, "backend-conflict", "A") + + +def test_create_recordset_conflict_with_dns_different_type(shared_zone_test_context): + """ + Test creating a new record set in a zone with the same name as an existing record + but with a different record type succeeds + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'already-exists', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'should succeed' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + text = [x['text'] for x in result_rs['records']] + assert_that(text, has_length(1)) + assert_that('should succeed', is_in(text)) + + print "\r\n\r\n!!!verifying recordset in dns backend" + answers = dns_resolve(shared_zone_test_context.ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(1)) + assert_that('"should succeed"', is_in(rdata_strings)) + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_recordset_zone_not_found(shared_zone_test_context): + """ + Test creating a new record set in a zone that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': '1234', + 'name': 'test_create_recordset_zone_not_found', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + client.create_recordset(new_rs, status=404) + + +def test_create_missing_record_data(shared_zone_test_context): + """ + Test that creating a record without providing necessary data returns errors + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = dict({"no": "data"}, zoneId=shared_zone_test_context.system_test_zone['id']) + + errors = client.create_recordset(new_rs, status=400)['errors'] + assert_that(errors, contains_inanyorder( + "Missing RecordSet.name", + "Missing RecordSet.type", + "Missing RecordSet.ttl" + )) + + +def test_create_invalid_record_type(shared_zone_test_context): + """ + Test that creating a record with invalid data returns errors + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_create_invalid_record_type', + 'type': 'invalid type', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + + errors = client.create_recordset(new_rs, status=400)['errors'] + assert_that(errors, contains_inanyorder("Invalid RecordType")) + + +def test_create_invalid_record_data(shared_zone_test_context): + """ + Test that creating a record with invalid data returns errors + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_create_invalid_record.data', + 'type': 'A', + 'ttl': 5, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': 'not.ipv4' + }, + { # Currently, list validation is fail-fast, so the "Missing A.address" that should happen here never does + 'nonsense': 'gibberish' + } + ] + } + + errors = client.create_recordset(new_rs, status=400)['errors'] + + import json + + print json.dumps(errors, indent=4) + assert_that(errors, contains_inanyorder( + "A must be a valid IPv4 Address", + "RecordSet.ttl must be a positive signed 32 bit number greater than or equal to 30" + )) + +def test_create_dotted_a_record_not_apex_fails(shared_zone_test_context): + """ + Test that creating a dotted host name A record set fails. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + dotted_host_a_record = { + 'zoneId': zone['id'], + 'name': 'hello.world', + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + + error = client.create_recordset(dotted_host_a_record, status=422) + assert_that(error, is_("Record with name " + dotted_host_a_record['name'] + " is a dotted host which " + "is illegal in this zone " + zone['name'])) + +def test_create_dotted_a_record_apex_succeeds(shared_zone_test_context): + """ + Test that creating an apex A record set containing dots succeeds. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + apex_a_record = { + 'zoneId': zone['id'], + 'name': zone['name'].rstrip('.'), + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + apex_a_rs = None + try: + apex_a_response = client.create_recordset(apex_a_record, status=202) + apex_a_rs = client.wait_until_recordset_change_status(apex_a_response, 'Complete')['recordSet'] + assert_that(apex_a_rs['name'],is_(apex_a_record['name'] + '.')) + + finally: + if apex_a_rs: + delete_result = client.delete_recordset(apex_a_rs['zoneId'], apex_a_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + +def test_create_dotted_a_record_apex_with_trailing_dot_succeeds(shared_zone_test_context): + """ + Test that creating an apex A record set containing dots succeeds (with trailing dot) + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + apex_a_record = { + 'zoneId': zone['id'], + 'name': zone['name'], + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + apex_a_rs = None + try: + apex_a_response = client.create_recordset(apex_a_record, status=202) + apex_a_rs = client.wait_until_recordset_change_status(apex_a_response, 'Complete')['recordSet'] + assert_that(apex_a_rs['name'],is_(apex_a_record['name'])) + + finally: + if apex_a_rs: + delete_result = client.delete_recordset(apex_a_rs['zoneId'], apex_a_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + +def test_create_dotted_cname_record_apex_fails(shared_zone_test_context): + """ + Test that creating a CNAME record set with record name matching dotted apex returns an error. + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + apex_cname_rs = { + 'zoneId': zone['id'], + 'name': zone['name'].rstrip('.'), + 'type': 'CNAME', + 'ttl': 500, + 'records': [{'cname': 'foo'}] + } + + errors = client.create_recordset(apex_cname_rs, status=400)['errors'] + assert_that(errors[0], is_("Record name cannot contain '.' with given type")) + +def test_create_cname_with_multiple_records(shared_zone_test_context): + """ + Test that creating a CNAME record set with multiple records returns an error + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_create_cname_with_multiple_records', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.com' + }, + { + 'cname': 'cname2.com' + } + ] + } + + errors = client.create_recordset(new_rs, status=400)['errors'] + assert_that(errors[0], is_("CNAME record sets cannot contain multiple records")) + + +def test_create_cname_pointing_to_origin_symbol_fails(shared_zone_test_context): + """ + Test that creating a CNAME record set with name '@' fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': '@', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname.' + } + ] + } + + error = client.create_recordset(new_rs, status=422) + assert_that(error, is_("CNAME RecordSet cannot have name '@' because it points to zone origin")) + + +def test_create_cname_with_existing_record_with_name_fails(shared_zone_test_context): + """ + Test that creating a CNAME fails if a record with the same name exists + """ + client = shared_zone_test_context.ok_vinyldns_client + + a_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'duplicate-test-name', + 'type': 'A', + 'ttl': 500, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + + cname_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'duplicate-test-name', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.com' + } + ] + } + + try: + a_create = client.create_recordset(a_rs, status=202) + a_record = client.wait_until_recordset_change_status(a_create, 'Complete')['recordSet'] + + error = client.create_recordset(cname_rs, status=409) + assert_that(error, is_('RecordSet with name duplicate-test-name already exists in zone system-test., CNAME record cannot use duplicate name')) + + finally: + delete_result = client.delete_recordset(a_record['zoneId'], a_record['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_record_with_existing_cname_fails(shared_zone_test_context): + """ + Test that creating a record fails if a cname with the same name exists + """ + client = shared_zone_test_context.ok_vinyldns_client + + cname_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'duplicate-test-name', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.com' + } + ] + } + + a_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'duplicate-test-name', + 'type': 'A', + 'ttl': 500, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + + try: + cname_create = client.create_recordset(cname_rs, status=202) + cname_record = client.wait_until_recordset_change_status(cname_create, 'Complete')['recordSet'] + + error = client.create_recordset(a_rs, status=409) + assert_that(error, is_('RecordSet with name duplicate-test-name and type CNAME already exists in zone system-test.')) + + finally: + delete_result = client.delete_recordset(cname_record['zoneId'], cname_record['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_cname_forces_record_to_be_absolute(shared_zone_test_context): + """ + Test that CNAME record data is made absolute after being created + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_create_cname_with_multiple_records', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.com' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'cname' : 'cname1.com.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_cname_relative_fails(shared_zone_test_context): + """ + Test that relative (no dots) CNAME record data fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_create_cname_relative', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'relative' + } + ] + } + + client.create_recordset(new_rs, status=400) + + +def test_create_cname_does_not_change_absolute_record(shared_zone_test_context): + """ + Test that CNAME record data that's already absolute is not changed after being created + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_create_cname_with_multiple_records', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'cname' : 'cname1.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_mx_forces_record_to_be_absolute(shared_zone_test_context): + """ + Test that MX exchange is made absolute after being created + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'mx_not_absolute', + 'type': 'MX', + 'ttl': 500, + 'records': [ + { + 'preference': 1, + 'exchange': 'foo' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'preference' : 1, 'exchange' : 'foo.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_mx_does_not_change_if_absolute(shared_zone_test_context): + """ + Test that MX exchange is unchanged if already absolute + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'mx_absolute', + 'type': 'MX', + 'ttl': 500, + 'records': [ + { + 'preference': 1, + 'exchange': 'foo.' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'preference' : 1, 'exchange' : 'foo.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_ptr_forces_record_to_be_absolute(shared_zone_test_context): + """ + Test that ptr record data is made absolute after being created + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.ip4_reverse_zone + + new_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '30.30', + 'type': 'PTR', + 'ttl': 500, + 'records': [ + { + 'ptrdname': 'foo' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'ptrdname' : 'foo.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_ptr_does_not_change_if_absolute(shared_zone_test_context): + """ + Test that ptr record data is unchanged if already absolute + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.ip4_reverse_zone + + new_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '30.30', + 'type': 'PTR', + 'ttl': 500, + 'records': [ + { + 'ptrdname': 'foo.' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'ptrdname' : 'foo.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_srv_forces_record_to_be_absolute(shared_zone_test_context): + """ + Test that srv target is made absolute after being created + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'srv_not_absolute', + 'type': 'SRV', + 'ttl': 500, + 'records': [ + { + 'priority': 1, + 'weight': 1, + 'port': 1, + 'target': 'foo' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'priority' : 1, 'weight' : 1, 'port' : 1, 'target' : 'foo.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_srv_does_not_change_if_absolute(shared_zone_test_context): + """ + Test that srv target is unchanged if already absolute + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'srv_absolute', + 'type': 'SRV', + 'ttl': 500, + 'records': [ + { + 'priority': 1, + 'weight': 1, + 'port': 1, + 'target': 'foo.' + } + ] + } + + try: + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + assert_that(result_rs['records'], is_([{'priority' : 1, 'weight' : 1, 'port' : 1, 'target' : 'foo.'}])) + finally: + if result_rs: + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +@pytest.mark.parametrize('record_name,test_rs', TestData.FORWARD_RECORDS) +def test_create_recordset_forward_record_types(shared_zone_test_context, record_name, test_rs): + """ + Test creating a new record set in an existing zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = dict(test_rs, zoneId=shared_zone_test_context.system_test_zone['id']) + + result = client.create_recordset(new_rs, status=202) + assert_that(result['status'], is_('Pending')) + print str(result) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = result_rs['records'] + + for record in new_rs['records']: + assert_that(records, has_item(has_entries(record))) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +@pytest.mark.parametrize('record_name,test_rs', TestData.REVERSE_RECORDS) +def test_reverse_create_recordset_reverse_record_types(shared_zone_test_context, record_name, test_rs): + """ + Test creating a new record set in an existing reverse zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = dict(test_rs, zoneId=shared_zone_test_context.ip4_reverse_zone['id']) + + result = client.create_recordset(new_rs, status=202) + assert_that(result['status'], is_('Pending')) + print str(result) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = result_rs['records'] + + for record in new_rs['records']: + assert_that(records, has_item(has_entries(record))) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +def test_create_invalid_recordset_name(shared_zone_test_context): + """ + Test creating a record set where the name is too long + """ + client = shared_zone_test_context.ok_vinyldns_client + + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'a'*256, + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + client.create_recordset(new_rs, status=400) + + +def test_user_cannot_create_record_in_unowned_zone(shared_zone_test_context): + """ + Test user can create a record that it a shared zone that it is a member of + """ + client = shared_zone_test_context.ok_vinyldns_client + new_record_set = { + 'zoneId': shared_zone_test_context.dummy_zone['id'], + 'name': 'test_user_cannot_create_record_in_unowned_zone', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.10.10.10' + } + ] + } + client.create_recordset(new_record_set, status=403) + + +def test_create_recordset_no_authorization(shared_zone_test_context): + """ + Test creating a new record set without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_create_recordset_no_authorization', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + client.create_recordset(new_rs, sign_request=False, status=401) + + +def test_create_ipv4_ptr_recordset_with_verify(shared_zone_test_context): + """ + Test creating a new IPv4 PTR recordset in an existing IPv4 reverse lookup zone + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.ip4_reverse_zone + result_rs = None + try: + new_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '30.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + print "\r\nCreating recordset in zone " + str(reverse4_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + records = result_rs['records'] + assert_that(records[0]['ptrdname'], is_('ftp.vinyldns.')) + + print "\r\n\r\n!!!verifying recordset in dns backend" + # verify that the record exists in the backend dns server + answers = dns_resolve(reverse4_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + assert_that(answers, has_length(1)) + assert_that(rdata_strings[0], is_('ftp.vinyldns.')) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + + +def test_create_ipv4_ptr_recordset_in_forward_zone_fails(shared_zone_test_context): + """ + Test creating a new IPv4 PTR record set in an existing forward lookup zone fails + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': '35.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + client.create_recordset(new_rs, status=422) + + +def test_create_address_recordset_in_ipv4_reverse_zone_fails(shared_zone_test_context): + """ + Test creating an A recordset in an existing IPv4 reverse lookup zone fails + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ip4_reverse_zone['id'], + 'name': 'test_create_address_recordset_in_ipv4_reverse_zone_fails', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + client.create_recordset(new_rs, status=422) + + +def test_create_ipv6_ptr_recordset(shared_zone_test_context): + """ + Test creating a new PTR record set in an existing IPv6 reverse lookup zone + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse6_zone = shared_zone_test_context.ip6_reverse_zone + result_rs = None + try: + new_rs = { + 'zoneId': reverse6_zone['id'], + 'name': '0.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + records = result_rs['records'] + assert_that(records[0]['ptrdname'], is_('ftp.vinyldns.')) + + print "\r\n\r\n!!!verifying recordset in dns backend" + answers = dns_resolve(reverse6_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + assert_that(answers, has_length(1)) + assert_that(rdata_strings[0], is_('ftp.vinyldns.')) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_ipv6_ptr_recordset_in_forward_zone_fails(shared_zone_test_context): + """ + Test creating a new PTR record set in an existing forward lookup zone fails + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': '3.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + client.create_recordset(new_rs, status=422) + + +def test_create_address_recordset_in_ipv6_reverse_zone_fails(shared_zone_test_context): + """ + Test creating a new A record set in an existing IPv6 reverse lookup zone fails + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ip6_reverse_zone['id'], + 'name': 'test_create_address_recordset_in_ipv6_reverse_zone_fails', + 'type': 'AAAA', + 'ttl': 100, + 'records': [ + { + 'address': 'fd69:27cc:fe91::60' + }, + { + 'address': 'fd69:27cc:fe91:1:2:3:4:61' + } + ] + } + client.create_recordset(new_rs, status=422) + + +def test_create_invalid_ipv6_ptr_recordset(shared_zone_test_context): + """ + Test creating an incorrect IPv6 PTR record in an existing IPv6 reverse lookup zone fails + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ip6_reverse_zone['id'], + 'name': '0.6.0.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + client.create_recordset(new_rs, status=422) + + +def test_at_create_recordset(shared_zone_test_context): + """ + Test creating a new record set with name @ in an existing zone + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': '@', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'someText' + } + ] + } + print "\r\nCreating recordset in zone " + str(ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + expected_rs = new_rs + expected_rs['name'] = ok_zone['name'] + verify_recordset(result_rs, expected_rs) + + print "\r\n\r\n!!!recordset verified..." + + records = result_rs['records'] + assert_that(records, has_length(1)) + assert_that(records[0]['text'], is_('someText')) + + print "\r\n\r\n!!!verifying recordset in dns backend" + # verify that the record exists in the backend dns server + answers = dns_resolve(ok_zone, ok_zone['name'], result_rs['type']) + + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(1)) + assert_that('"someText"', is_in(rdata_strings)) + finally: + if result_rs: + client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(result_rs['zoneId'], result_rs['id']) + +def test_create_record_with_escape_characters_in_record_data_succeeds(shared_zone_test_context): + """ + Test creating a new record set with escape characters (i.e. "" and \) in the record data + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': 'testing', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'escaped\char"act"ers' + } + ] + } + print "\r\nCreating recordset in zone " + str(ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + expected_rs = new_rs + expected_rs['name'] = 'testing' + verify_recordset(result_rs, expected_rs) + + print "\r\n\r\n!!!recordset verified..." + + records = result_rs['records'] + assert_that(records, has_length(1)) + assert_that(records[0]['text'], is_('escaped\\char\"act\"ers')) + + print "\r\n\r\n!!!verifying recordset in dns backend" + # verify that the record exists in the backend dns server + answers = dns_resolve(ok_zone, 'testing', result_rs['type']) + + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(1)) + assert_that('\"escapedchar\\"act\\"ers\"', is_in(rdata_strings)) + finally: + if result_rs: + client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(result_rs['zoneId'], result_rs['id']) + + + +def test_create_record_with_existing_wildcard_succeeds(shared_zone_test_context): + """ + Test that creating a record when a wildcard record of the same type already exists succeeds + """ + client = shared_zone_test_context.ok_vinyldns_client + + wildcard_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': '*', + 'type': 'TXT', + 'ttl': 500, + 'records': [ + { + 'text': 'wildcard func test 1' + } + ] + } + + test_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'create-record-with-existing-wildcard-succeeds', + 'type': 'TXT', + 'ttl': 500, + 'records': [ + { + 'text': 'wildcard this should be ok' + } + ] + } + + try: + wildcard_create = client.create_recordset(wildcard_rs, status=202) + wildcard_rs = client.wait_until_recordset_change_status(wildcard_create, 'Complete')['recordSet'] + + test_create = client.create_recordset(test_rs, status=202) + test_rs = client.wait_until_recordset_change_status(test_create, 'Complete')['recordSet'] + except: + pass + finally: + try: + delete_result = client.delete_recordset(wildcard_rs['zoneId'], wildcard_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + finally: + try: + delete_result = client.delete_recordset(test_rs['zoneId'], test_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass + + +def test_dotted_host_create_fails(shared_zone_test_context): + """ + Tests that a dotted host recordset create fails + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'record-with.dot', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'should fail' + } + ] + } + error = client.create_recordset(new_rs, status=422) + assert_that(error, is_('Record with name record-with.dot is a dotted host which is illegal in this zone ok.')) + + +def test_ns_create_for_non_approved_group_fails(shared_zone_test_context): + """ + Tests that an ns change on a group whose admin group is not approved fails (only ok group is approved) + """ + client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.parent_zone + + new_rs = { + 'zoneId': zone['id'], + 'name': 'someNS', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + error = client.create_recordset(new_rs, status=403) + assert_that(error, is_('Do not have permissions to manage NS recordsets, please contact vinyldns-support')) + + +def test_ns_create_for_approved_group_passes(shared_zone_test_context): + """ + Tests that an ns change on a group whose admin group is approved passes (only ok group is approved) + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + result_rs = None + + try: + new_rs = { + 'zoneId': zone['id'], + 'name': 'someNS', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + finally: + if result_rs: + client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202,404)) + client.wait_until_recordset_deleted(result_rs['zoneId'], result_rs['id']) + + +def test_ns_create_for_origin_fails(shared_zone_test_context): + """ + Tests that an ns create for origin fails + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + new_rs = { + 'zoneId': zone['id'], + 'name': '@', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + client.create_recordset(new_rs, status=409) + + +def test_create_ipv4_ptr_recordset_with_verify_in_classless(shared_zone_test_context): + """ + Test creating a new IPv4 PTR record set in an existing IPv4 classless delegation zone + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.classless_zone_delegation_zone + result_rs = None + + try: + new_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '196', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + print "\r\nCreating recordset in zone " + str(reverse4_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + records = result_rs['records'] + assert_that(records[0]['ptrdname'], is_('ftp.vinyldns.')) + + print "\r\n\r\n!!!verifying recordset in dns backend" + # verify that the record exists in the backend dns server + answers = dns_resolve(reverse4_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + assert_that(answers, has_length(1)) + assert_that(rdata_strings[0], is_('ftp.vinyldns.')) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_create_ipv4_ptr_recordset_in_classless_outside_cidr(shared_zone_test_context): + """ + Test new IPv4 PTR recordset fails outside the cidr range for a IPv4 classless delegation zone + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.classless_zone_delegation_zone + + new_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '190', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + + error = client.create_recordset(new_rs, status=422) + assert_that(error, is_('RecordSet 190 does not specify a valid IP address in zone 192/30.2.0.192.in-addr.arpa.')) diff --git a/modules/api/functional_test/live_tests/recordsets/delete_recordset_test.py b/modules/api/functional_test/live_tests/recordsets/delete_recordset_test.py new file mode 100644 index 0000000000..f58625e633 --- /dev/null +++ b/modules/api/functional_test/live_tests/recordsets/delete_recordset_test.py @@ -0,0 +1,642 @@ +import pytest +import sys +from utils import * + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from test_data import TestData +import time + + +@pytest.mark.parametrize('record_name,test_rs', TestData.FORWARD_RECORDS) +def test_delete_recordset_forward_record_types(shared_zone_test_context, record_name, test_rs): + """ + Test deleting a recordset for forward record types + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = dict(test_rs, zoneId=shared_zone_test_context.system_test_zone['id']) + + result = client.create_recordset(new_rs, status=202) + assert_that(result['status'], is_('Pending')) + print str(result) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = result_rs['records'] + + for record in new_rs['records']: + assert_that(records, has_item(has_entries(record))) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # now delete + delete_rs = result_rs + + result = client.delete_recordset(delete_rs['zoneId'], delete_rs['id'], status=202) + assert_that(result['status'], is_('Pending')) + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # retry until the recordset is not found + client.get_recordset(result_rs['zoneId'], result_rs['id'], retries=20, status=404) + result_rs = None + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +@pytest.mark.parametrize('record_name,test_rs', TestData.REVERSE_RECORDS) +def test_delete_recordset_reverse_record_types(shared_zone_test_context, record_name, test_rs): + """ + Test deleting a recordset for reverse record types + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = dict(test_rs, zoneId=shared_zone_test_context.ip4_reverse_zone['id']) + + result = client.create_recordset(new_rs, status=202) + assert_that(result['status'], is_('Pending')) + print str(result) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = result_rs['records'] + + for record in new_rs['records']: + assert_that(records, has_item(has_entries(record))) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # now delete + delete_rs = result_rs + + result = client.delete_recordset(delete_rs['zoneId'], delete_rs['id'], status=202) + assert_that(result['status'], is_('Pending')) + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # retry until the recordset is not found + client.get_recordset(result_rs['zoneId'], result_rs['id'], retries=20, status=404) + result_rs = None + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +def test_delete_recordset_with_verify(shared_zone_test_context): + """ + Test deleting a new record set removes it from the backend + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_delete_recordset_with_verify', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + print "\r\nCreating recordset in zone " + str(shared_zone_test_context.ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(2)) + assert_that('10.1.1.1', is_in(records)) + assert_that('10.2.2.2', is_in(records)) + + print "\r\n\r\n!!!verifying recordset in dns backend" + # verify that the record exists in the backend dns server + answers = dns_resolve(shared_zone_test_context.ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(2)) + assert_that('10.1.1.1', is_in(rdata_strings)) + assert_that('10.2.2.2', is_in(rdata_strings)) + + # Delete the record set and verify that it is removed + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + answers = dns_resolve(shared_zone_test_context.ok_zone, result_rs['name'], result_rs['type']) + not_found = len(answers) == 0 + + assert_that(not_found, is_(True)) + + result_rs = None + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_can_delete_record_in_owned_zone(shared_zone_test_context): + """ + Test user can delete a record that in a zone that it is owns + """ + + client = shared_zone_test_context.ok_vinyldns_client + rs = None + try: + rs = client.create_recordset( + { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_user_can_delete_record_in_owned_zone', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.10.10.10' + } + ] + }, status=202)['recordSet'] + client.wait_until_recordset_exists(rs['zoneId'], rs['id']) + + client.delete_recordset(rs['zoneId'], rs['id'], status=202) + client.wait_until_recordset_deleted(rs['zoneId'], rs['id']) + rs = None + finally: + if rs: + try: + client.delete_recordset(rs['zoneId'], rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(rs['zoneId'], rs['id']) + finally: + pass + + +def test_user_cannot_delete_record_in_unowned_zone(shared_zone_test_context): + """ + Test user cannot delete a record that in an unowned zone + """ + + client = shared_zone_test_context.dummy_vinyldns_client + unauthorized_client = shared_zone_test_context.ok_vinyldns_client + rs = None + try: + rs = client.create_recordset( + { + 'zoneId': shared_zone_test_context.dummy_zone['id'], + 'name': 'test-user-cannot-delete-record-in-unowned-zone', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.10.10.10' + } + ] + }, status=202)['recordSet'] + + client.wait_until_recordset_exists(rs['zoneId'], rs['id']) + unauthorized_client.delete_recordset(rs['zoneId'], rs['id'], status=403) + finally: + if rs: + try: + client.delete_recordset(rs['zoneId'], rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(rs['zoneId'], rs['id']) + finally: + pass + + +def test_delete_recordset_no_authorization(shared_zone_test_context): + """ + Test delete a recordset without authorization + """ + client = shared_zone_test_context.dummy_vinyldns_client + client.delete_recordset(shared_zone_test_context.ok_zone['id'], '1234', sign_request=False, status=401) + + +def test_delete_ipv4_ptr_recordset(shared_zone_test_context): + """ + Test deleting an IPv4 PTR recordset deletes the record + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.ip4_reverse_zone + result_rs = None + + try: + orig_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '30.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + result = client.create_recordset(orig_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Deleting..." + + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + result_rs = None + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_delete_ipv4_ptr_recordset_does_not_exist_fails(shared_zone_test_context): + """ + Test deleting a nonexistant IPv4 PTR recordset returns not found + """ + client =shared_zone_test_context.ok_vinyldns_client + client.delete_recordset(shared_zone_test_context.ip4_reverse_zone['id'], '4444', status=404) + + +def test_delete_ipv6_ptr_recordset(shared_zone_test_context): + """ + Test deleting an IPv6 PTR recordset deletes the record + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + orig_rs = { + 'zoneId': shared_zone_test_context.ip6_reverse_zone['id'], + 'name': '0.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + result = client.create_recordset(orig_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Deleting..." + + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + result_rs = None + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + + +def test_delete_ipv6_ptr_recordset_does_not_exist_fails(shared_zone_test_context): + """ + Test deleting a nonexistant IPv6 PTR recordset returns not found + """ + client = shared_zone_test_context.ok_vinyldns_client + client.delete_recordset(shared_zone_test_context.ip6_reverse_zone['id'], '6666', status=404) + + +def test_delete_recordset_zone_not_found(shared_zone_test_context): + """ + Test deleting a recordset in a zone that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + client.delete_recordset('1234', '4567', status=404) + + +def test_delete_recordset_not_found(shared_zone_test_context): + """ + Test deleting a recordset that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + client.delete_recordset(shared_zone_test_context.ok_zone['id'], '1234', status=404) + + +def test_at_delete_recordset(shared_zone_test_context): + """ + Test deleting a recordset with name @ in an existing zone + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + new_rs = { + 'zoneId': ok_zone['id'], + 'name': '@', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'someText' + } + ] + } + print "\r\nCreating recordset in zone " + str(ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + + print json.dumps(result, indent=3) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + expected_rs = new_rs + expected_rs['name'] = ok_zone['name'] + verify_recordset(result_rs, expected_rs) + + print "\r\n\r\n!!!recordset verified..." + + records = result_rs['records'] + assert_that(records, has_length(1)) + assert_that(records[0]['text'], is_('someText')) + + print "\r\n\r\n!!!deleting recordset in dns backend" + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + # verify that the record does not exist in the backend dns server + answers = dns_resolve(ok_zone, ok_zone['name'], result_rs['type']) + not_found = len(answers) == 0 + assert_that(not_found) + + +def test_delete_recordset_with_different_dns_data(shared_zone_test_context): + """ + Test deleting a recordset with out-of-sync rdata in dns (ex. if the record was modified manually) + """ + + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': 'test_delete_recordset_with_different_dns_data', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + print "\r\nCreating recordset in zone " + str(ok_zone) + "\r\n" + result = client.create_recordset(new_rs, status=202) + print str(result) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + result_rs['records'][0]['address'] = "10.8.8.8" + result = client.update_recordset(result_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + print "\r\n\r\n!!!verifying recordset in dns backend" + answers = dns_resolve(ok_zone, result_rs['name'], result_rs['type']) + assert_that(answers, has_length(1)) + + response = dns_update(ok_zone, result_rs['name'], 300, result_rs['type'], '10.9.9.9') + print "\nSuccessfully updated the record, record is now out of sync\n" + print str(response) + + # check you can delete + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + result_rs = None + + finally: + if result_rs: + try: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if delete_result: + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass + + +def test_user_can_delete_record_via_user_acl_rule(shared_zone_test_context): + """ + Test user DELETE ACL rule - delete + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Delete', userId='dummy') + + result_rs = seed_text_recordset(client, "test_user_can_delete_record_via_user_acl_rule", ok_zone) + + #Dummy user cannot delete record in zone + shared_zone_test_context.dummy_vinyldns_client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=403, retries=3) + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can delete record + shared_zone_test_context.dummy_vinyldns_client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_deleted(result_rs['zoneId'], result_rs['id']) + result_rs = None + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_cannot_delete_record_with_write_txt_read_all(shared_zone_test_context): + """ + Test user WRITE TXT READ all ACL rule + """ + client = shared_zone_test_context.ok_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + created_rs = None + try: + acl_rule1 = generate_acl_rule('Read', userId='dummy', recordMask='www-*') + acl_rule2 = generate_acl_rule('Write', userId='dummy', recordMask='www-user-cant-delete', recordTypes=['TXT']) + + add_ok_acl_rules(shared_zone_test_context, [acl_rule1, acl_rule2]) + + # verify dummy can see ok_zone + dummy_view = dummy_client.list_zones()['zones'] + zone_ids = [zone['id'] for zone in dummy_view] + assert_that(zone_ids, has_item(ok_zone['id'])) + + # dummy should be able to add the RS + new_rs = get_recordset_json(ok_zone, "www-user-cant-delete", "TXT", [{'text':'should-work'}]) + rs_change = dummy_client.create_recordset(new_rs, status=202) + created_rs = client.wait_until_recordset_change_status(rs_change, 'Complete')['recordSet'] + verify_recordset(created_rs, new_rs) + + #dummy cannot delete the RS + dummy_client.delete_recordset(ok_zone['id'], created_rs['id'], status=403) + + finally: + clear_ok_acl_rules(shared_zone_test_context) + if created_rs: + delete_result = client.delete_recordset(created_rs['zoneId'], created_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_can_delete_record_via_group_acl_rule(shared_zone_test_context): + """ + Test group DELETE ACL rule - delete + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Delete', groupId=shared_zone_test_context.dummy_group['id']) + + result_rs = seed_text_recordset(client, "test_user_can_delete_record_via_group_acl_rule", ok_zone) + + #Dummy user cannot delete record in zone + shared_zone_test_context.dummy_vinyldns_client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=403) + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can delete record + shared_zone_test_context.dummy_vinyldns_client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_deleted(result_rs['zoneId'], result_rs['id']) + result_rs = None + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_ns_delete_for_non_approved_group_fails(shared_zone_test_context): + """ + Tests that someone not in the approved group could not delete a ns record + """ + client = shared_zone_test_context.ok_vinyldns_client + not_approved_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.parent_zone + + ns_rs = None + try: + new_rs = { + 'zoneId': zone['id'], + 'name': 'someNS', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + ns_rs = result['recordSet'] + + assert_that(client.wait_until_recordset_exists(ns_rs['zoneId'], ns_rs['id'])) + + error = not_approved_client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=403) + assert_that(error, is_('Do not have permissions to manage NS recordsets, please contact vinyldns-support')) + + finally: + if ns_rs: + client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=(202,404)) + client.wait_until_recordset_deleted(ns_rs['zoneId'], ns_rs['id']) + +def test_ns_delete_for_approved_group_passes(shared_zone_test_context): + """ + Tests that an ns delete on a group whose admin group is approved passes (only ok group is approved) + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + ns_rs = None + + try: + new_rs = { + 'zoneId': zone['id'], + 'name': 'someNS', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + ns_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + delete_result = client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + ns_rs = None + + finally: + if ns_rs: + client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=(202,404)) + client.wait_until_recordset_deleted(ns_rs['zoneId'], ns_rs['id']) + + +def test_ns_delete_existing_ns_origin_fails(shared_zone_test_context): + """ + Tests that an ns delete for existing ns origin fails + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + list_results_page = client.list_recordsets(zone['id'], status=200)['recordSets'] + + apex_ns = [item for item in list_results_page if item['type'] == 'NS' and item['name'] in zone['name']][0] + + client.delete_recordset(apex_ns['zoneId'], apex_ns['id'], status=422) + +def test_delete_dotted_a_record_apex_succeeds(shared_zone_test_context): + """ + Test that creating an apex A record set containing dots succeeds. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + apex_a_record = { + 'zoneId': zone['id'], + 'name': zone['name'].rstrip('.'), + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + try: + apex_a_response = client.create_recordset(apex_a_record, status=202) + apex_a_rs = client.wait_until_recordset_change_status(apex_a_response, 'Complete')['recordSet'] + assert_that(apex_a_rs['name'],is_(apex_a_record['name'] + '.')) + + finally: + delete_result = client.delete_recordset(apex_a_rs['zoneId'], apex_a_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') diff --git a/modules/api/functional_test/live_tests/recordsets/get_recordset_test.py b/modules/api/functional_test/live_tests/recordsets/get_recordset_test.py new file mode 100644 index 0000000000..3cbabb185f --- /dev/null +++ b/modules/api/functional_test/live_tests/recordsets/get_recordset_test.py @@ -0,0 +1,130 @@ +import pytest +import uuid + +from utils import * +from hamcrest import * +from vinyldns_python import VinylDNSClient + +def test_get_recordset_no_authorization(shared_zone_test_context): + """ + Test getting a recordset without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + client.get_recordset(shared_zone_test_context.ok_zone['id'], '12345', sign_request=False, status=401) + + +def test_get_recordset(shared_zone_test_context): + """ + Test getting a recordset + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_get_recordset', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result = client.create_recordset(new_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # Get the recordset we just made and verify + result = client.get_recordset(result_rs['zoneId'], result_rs['id']) + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(2)) + assert_that('10.1.1.1', is_in(records)) + assert_that('10.2.2.2', is_in(records)) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_get_recordset_zone_doesnt_exist(shared_zone_test_context): + """ + Test getting a recordset in a zone that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_get_recordset_zone_doesnt_exist', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result_rs = None + try: + result = client.create_recordset(new_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + client.get_recordset('5678', result_rs['id'], status=404) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_get_recordset_doesnt_exist(shared_zone_test_context): + """ + Test getting a new recordset that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + client.get_recordset(shared_zone_test_context.ok_zone['id'], '123', status=404) + + +def test_at_get_recordset(shared_zone_test_context): + """ + Test getting a recordset with name @ + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': '@', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'someText' + } + ] + } + result = client.create_recordset(new_rs, status=202) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # Get the recordset we just made and verify + result = client.get_recordset(result_rs['zoneId'], result_rs['id']) + result_rs = result['recordSet'] + + expected_rs = new_rs + expected_rs['name'] = ok_zone['name'] + verify_recordset(result_rs, expected_rs) + + records = result_rs['records'] + assert_that(records, has_length(1)) + assert_that(records[0]['text'], is_('someText')) + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') diff --git a/modules/api/functional_test/live_tests/recordsets/list_recordset_changes_test.py b/modules/api/functional_test/live_tests/recordsets/list_recordset_changes_test.py new file mode 100644 index 0000000000..7795385a1d --- /dev/null +++ b/modules/api/functional_test/live_tests/recordsets/list_recordset_changes_test.py @@ -0,0 +1,176 @@ +from hamcrest import * +from utils import * +from vinyldns_python import VinylDNSClient + + +def check_changes_response(response, recordChanges=False, nextId=False, startFrom=False, maxItems=100): + """ + :param response: return value of list_recordset_changes() + :param recordChanges: true if not empty or False if empty, cannot check exact values because don't have access to all attributes + :param nextId: true if exists, false if doesn't, wouldn't be able to check exact value + :param startFrom: the string for startFrom or false if doesnt exist + :param maxItems: maxItems is defined as an Int by default so will always return an Int + """ + + assert_that(response, has_key('zoneId')) #always defined as random string + if recordChanges: + assert_that(response['recordSetChanges'], is_not(has_length(0))) + else: + assert_that(response['recordSetChanges'], has_length(0)) + if nextId: + assert_that(response, has_key('nextId')) + else: + assert_that(response, is_not(has_key('nextId'))) + if startFrom: + assert_that(response['startFrom'], is_(startFrom)) + else: + assert_that(response, is_not(has_key('startFrom'))) + assert_that(response['maxItems'], is_(maxItems)) + + for change in response['recordSetChanges']: + assert_that(change['userName'], is_('history-user')) + + +def test_list_recordset_changes_no_authorization(shared_zone_test_context): + """ + Test that recordset changes without authorization fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + client.list_recordset_changes('12345', sign_request=False, status=401) + + +def test_list_recordset_changes_member_auth_success(shared_zone_test_context): + """ + Test recordset changes succeeds with membership auth for member of admin group + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.ok_zone + client.list_recordset_changes(zone['id'], status=200) + + +def test_list_recordset_changes_member_auth_no_access(shared_zone_test_context): + """ + Test recordset changes fails for user not in admin group with no acl rules + """ + client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.ok_zone + client.list_recordset_changes(zone['id'], status=403) + + +def test_list_recordset_changes_member_auth_with_acl(shared_zone_test_context): + """ + Test recordset changes succeeds for user with acl rules + """ + zone = shared_zone_test_context.ok_zone + acl_rule = generate_acl_rule('Write', userId='dummy') + try: + client = shared_zone_test_context.dummy_vinyldns_client + + client.list_recordset_changes(zone['id'], status=403) + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + client.list_recordset_changes(zone['id'], status=200) + finally: + clear_ok_acl_rules(shared_zone_test_context) + + +def test_list_recordset_changes_no_start(zone_history_context): + """ + Test getting all recordset changes on one page (max items will default to default value) + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + response = client.list_recordset_changes(original_zone['id'], start_from=None, max_items=None) + check_changes_response(response, recordChanges=True, startFrom=False, nextId=False) + + deleteChanges = response['recordSetChanges'][0:3] + updateChanges = response['recordSetChanges'][3:6] + createChanges = response['recordSetChanges'][6:9] + + for change in deleteChanges: + assert_that(change['changeType'], is_('Delete')) + for change in updateChanges: + assert_that(change['changeType'], is_('Update')) + for change in createChanges: + assert_that(change['changeType'], is_('Create')) + + +def test_list_recordset_changes_paging(zone_history_context): + """ + Test paging for recordset changes can use previous nextId as start key of next page + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + response_1 = client.list_recordset_changes(original_zone['id'], start_from=None, max_items=3) + response_2 = client.list_recordset_changes(original_zone['id'], start_from=response_1['nextId'], max_items=3) + # nextId differs local/in dev where we get exactly the last item + # Requesting one over the total in the local in memory dynamo will force consistent behavior. + response_3 = client.list_recordset_changes(original_zone['id'], start_from=response_2['nextId'], max_items=11) + + check_changes_response(response_1, recordChanges=True, nextId=True, startFrom=False, maxItems=3) + check_changes_response(response_2, recordChanges=True, nextId=True, startFrom=response_1['nextId'], maxItems=3) + check_changes_response(response_3, recordChanges=True, nextId=False, startFrom=response_2['nextId'], maxItems=11) + + for change in response_1['recordSetChanges']: + assert_that(change['changeType'], is_('Delete')) + for change in response_2['recordSetChanges']: + assert_that(change['changeType'], is_('Update')) + for change in response_3['recordSetChanges']: + assert_that(change['changeType'], is_('Create')) + + +def test_list_recordset_changes_exhausted(zone_history_context): + """ + Test next id is none when zone changes are exhausted + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + response = client.list_recordset_changes(original_zone['id'], start_from=None, max_items=17) + check_changes_response(response, recordChanges=True, startFrom=False, nextId=False, maxItems=17) + + deleteChanges = response['recordSetChanges'][0:3] + updateChanges = response['recordSetChanges'][3:6] + createChanges = response['recordSetChanges'][6:9] + + for change in deleteChanges: + assert_that(change['changeType'], is_('Delete')) + for change in updateChanges: + assert_that(change['changeType'], is_('Update')) + for change in createChanges: + assert_that(change['changeType'], is_('Create')) + + +def test_list_recordset_returning_no_changes(zone_history_context): + """ + Pass in startFrom of 0 should return empty list because start key is created time + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + response = client.list_recordset_changes(original_zone['id'], start_from='0', max_items=None) + check_changes_response(response, recordChanges=False, startFrom='0', nextId=False) + + +def test_list_recordset_changes_default_max_items(zone_history_context): + """ + Test default max items is 100 + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + response = client.list_recordset_changes(original_zone['id'], start_from=None, max_items=None) + check_changes_response(response, recordChanges=True, startFrom=False, nextId=False, maxItems=100) + + +def test_list_recordset_changes_max_items_boundaries(zone_history_context): + """ + Test 0 < max_items <= 100 + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + too_large = client.list_recordset_changes(original_zone['id'], start_from=None, max_items=101, status=400) + too_small = client.list_recordset_changes(original_zone['id'], start_from=None, max_items=0, status=400) + + assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive")) + assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive")) diff --git a/modules/api/functional_test/live_tests/recordsets/list_recordsets_test.py b/modules/api/functional_test/live_tests/recordsets/list_recordsets_test.py new file mode 100644 index 0000000000..78ddd5a517 --- /dev/null +++ b/modules/api/functional_test/live_tests/recordsets/list_recordsets_test.py @@ -0,0 +1,303 @@ +import pytest +import sys +from utils import * + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from test_data import TestData + + +class ListRecordSetsFixture(): + def __init__(self, shared_zone_test_context): + self.test_context = shared_zone_test_context.ok_zone + self.client = shared_zone_test_context.ok_vinyldns_client + self.new_rs = {} + existing_records = self.client.list_recordsets(self.test_context['id'])['recordSets'] + assert_that(existing_records, has_length(7)) + rs_template = { + 'zoneId': self.test_context['id'], + 'name': '00-test-list-recordsets-', + 'type': '', + 'ttl': 100, + 'records': [0] + } + rs_types = [ + ['A', + [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + ], + ['CNAME', + [ + { + 'cname': 'cname1.' + } + ] + ] + ] + self.all_records = {} + result_list = {} + for i in range(10): + self.all_records[i] = copy.deepcopy(rs_template) + self.all_records[i]['type'] = rs_types[(i % 2)][0] + self.all_records[i]['records'] = rs_types[(i % 2)][1] + self.all_records[i]['name'] = "{0}{1}-{2}".format(self.all_records[i]['name'], i, self.all_records[i]['type']) + result_list[i] = self.client.create_recordset(self.all_records[i], status=202) + self.client.wait_until_recordset_change_status(result_list[i], 'Complete') + self.new_rs[i] = result_list[i]['recordSet'] + + for i in range(7): + self.all_records[i + 10] = existing_records[i] + + def tear_down(self): + for key in self.new_rs: + self.client.delete_recordset(self.new_rs[key]['zoneId'], self.new_rs[key]['id'], status=202) + for key in self.new_rs: + self.client.wait_until_recordset_deleted(self.new_rs[key]['zoneId'], self.new_rs[key]['id']) + + def check_recordsets_page_accuracy(self, list_results_page, size, offset, nextId=False, startFrom=False, maxItems=100): + # validate fields + if nextId: + assert_that(list_results_page, has_key('nextId')) + else: + assert_that(list_results_page, is_not(has_key('nextId'))) + if startFrom: + assert_that(list_results_page['startFrom'], is_(startFrom)) + else: + assert_that(list_results_page, is_not(has_key('startFrom'))) + assert_that(list_results_page['maxItems'], is_(maxItems)) + + # validate actual page + list_results_recordSets_page = list_results_page['recordSets'] + assert_that(list_results_recordSets_page, has_length(size)) + for i in range(len(list_results_recordSets_page)): + assert_that(list_results_recordSets_page[i]['name'], is_(self.all_records[i+offset]['name'])) + verify_recordset(list_results_recordSets_page[i], self.all_records[i+offset]) + assert_that(list_results_recordSets_page[i]['accessLevel'], is_('Delete')) + + +@pytest.fixture(scope = "module") +def rs_fixture(request, shared_zone_test_context): + + fix = ListRecordSetsFixture(shared_zone_test_context) + def fin(): + fix.tear_down() + + request.addfinalizer(fin) + + return fix + + +def test_list_recordsets_no_start(rs_fixture): + """ + Test listing all recordsets + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + list_results = client.list_recordsets(ok_zone['id'], status=200) + rs_fixture.check_recordsets_page_accuracy(list_results, size=17, offset=0) + + +def test_list_recordsets_multiple_pages(rs_fixture): + """ + Test listing record sets in pages, using nextId from previous page for new one + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + # first page of 2 items + list_results_page = client.list_recordsets(ok_zone['id'], max_items=2, status=200) + rs_fixture.check_recordsets_page_accuracy(list_results_page, size=2, offset=0, nextId=True, maxItems=2) + + # second page of 5 items + start = list_results_page['nextId'] + list_results_page = client.list_recordsets(ok_zone['id'], start_from=start, max_items=5, status=200) + rs_fixture.check_recordsets_page_accuracy(list_results_page, size=5, offset=2, nextId=True, startFrom=start, maxItems=5) + + # third page of 4 items + start = list_results_page['nextId'] + # nextId differs local/in dev where we get exactly the last item + # If you put 3 items in local in memory dynamo and request three items, you always get an exclusive start key, + # but in real dynamo you don't. Requesting something over 4 will force consistent behavior + list_results_page = client.list_recordsets(ok_zone['id'], start_from=start, max_items=11, status=200) + rs_fixture.check_recordsets_page_accuracy(list_results_page, size=10, offset=7, nextId=False, startFrom=start, maxItems=11) + + +def test_list_recordsets_excess_page_size(rs_fixture): + """ + Test listing record set with page size larger than record sets count returns all records and nextId of None + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + #page of 19 items + list_results_page = client.list_recordsets(ok_zone['id'], max_items=19, status=200) + rs_fixture.check_recordsets_page_accuracy(list_results_page, size=17, offset=0, maxItems=19, nextId=False) + + +def test_list_recordsets_fails_max_items_too_large(rs_fixture): + """ + Test listing record set with page size larger than max page size + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + client.list_recordsets(ok_zone['id'], max_items=200, status=400) + + +def test_list_recordsets_fails_max_items_too_small(rs_fixture): + """ + Test listing record set with page size of zero + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + client.list_recordsets(ok_zone['id'], max_items=0, status=400) + + +def test_list_recordsets_default_size_is_100(rs_fixture): + """ + Test default page size is 100 + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + list_results = client.list_recordsets(ok_zone['id'], status=200) + rs_fixture.check_recordsets_page_accuracy(list_results, size=17, offset=0, maxItems=100) + + +def test_list_recordsets_with_record_name_filter_all(rs_fixture): + """ + Test listing all recordsets whose name contains a substring, all recordsets have substring 'list' in name + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + list_results = client.list_recordsets(ok_zone['id'], record_name_filter="list", status=200) + rs_fixture.check_recordsets_page_accuracy(list_results, size=10, offset=0) + + +def test_list_recordsets_with_record_name_filter_and_page_size(rs_fixture): + """ + First Listing 4 out of 5 recordsets with substring 'CNAME' in name + Second Listing 5 out of 5 recordsets with substring 'CNAME' in name with an excess page size of 7 + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + #page of 4 items + list_results = client.list_recordsets(ok_zone['id'], max_items=4, record_name_filter="CNAME", status=200) + assert_that(list_results['recordSets'], has_length(4)) + + list_results_records = list_results['recordSets']; + for i in range(len(list_results_records)): + assert_that(list_results_records[i]['name'], contains_string('CNAME')) + + #page of 5 items but excess max items + list_results = client.list_recordsets(ok_zone['id'], max_items=7, record_name_filter="CNAME", status=200) + assert_that(list_results['recordSets'], has_length(5)) + + list_results_records = list_results['recordSets']; + for i in range(len(list_results_records)): + assert_that(list_results_records[i]['name'], contains_string('CNAME')) + + +def test_list_recordsets_with_record_name_filter_and_chaining_pages_with_nextId(rs_fixture): + """ + First Listing 2 out 5 recordsets with substring 'CNAME' in name, then using next Id of + previous page to be the start key of next page + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + #page of 2 items + list_results = client.list_recordsets(ok_zone['id'], max_items=2, record_name_filter="CNAME", status=200) + assert_that(list_results['recordSets'], has_length(2)) + start_key = list_results['nextId'] + + #page of 2 items + list_results = client.list_recordsets(ok_zone['id'], start_from=start_key, max_items=2, record_name_filter="CNAME", status=200) + assert_that(list_results['recordSets'], has_length(2)) + + list_results_records = list_results['recordSets']; + assert_that(list_results_records[0]['name'], contains_string('5')) + assert_that(list_results_records[1]['name'], contains_string('7')) + + +def test_list_recordsets_with_record_name_filter_one(rs_fixture): + """ + Test listing all recordsets whose name contains a substring, only one record set has substring '8' in name + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + list_results = client.list_recordsets(ok_zone['id'], record_name_filter="8", status=200) + rs_fixture.check_recordsets_page_accuracy(list_results, size=1, offset=8) + + +def test_list_recordsets_with_record_name_filter_none(rs_fixture): + """ + Test listing all recordsets whose name contains a substring, no record set has substring 'Dummy' in name + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + + list_results = client.list_recordsets(ok_zone['id'], record_name_filter="Dummy", status=200) + rs_fixture.check_recordsets_page_accuracy(list_results, size=0, offset=0) + + +def test_list_recordsets_no_authorization(rs_fixture): + """ + Test listing record sets without authorization + """ + client = rs_fixture.client + ok_zone = rs_fixture.test_context + client.list_recordsets(ok_zone['id'], sign_request=False, status=401) + + +def test_list_recordsets_with_acl(shared_zone_test_context): + """ + Test listing all recordsets + """ + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + new_rs = [] + + try: + acl_rule1 = generate_acl_rule('Read', groupId=shared_zone_test_context.dummy_group['id'], recordMask='test.*') + acl_rule2 = generate_acl_rule('Write', userId='dummy', recordMask='test-list-recordsets-with-acl1') + + rec1 = seed_text_recordset(client, "test-list-recordsets-with-acl1", ok_zone) + rec2 = seed_text_recordset(client, "test-list-recordsets-with-acl2", ok_zone) + rec3 = seed_text_recordset(client, "BAD-test-list-recordsets-with-acl", ok_zone) + + new_rs = [rec1, rec2, rec3] + + add_ok_acl_rules(shared_zone_test_context, [acl_rule1, acl_rule2]) + + result = shared_zone_test_context.dummy_vinyldns_client.list_recordsets(ok_zone['id'], status=200) + result = result['recordSets'] + + for rs in result: + if rs['name'] == rec1['name']: + verify_recordset(rs, rec1) + assert_that(rs['accessLevel'], is_('Write')) + elif rs['name'] == rec2['name']: + verify_recordset(rs, rec2) + assert_that(rs['accessLevel'], is_('Read')) + elif rs['name'] == rec3['name']: + verify_recordset(rs, rec3) + assert_that(rs['accessLevel'], is_('NoAccess')) + + finally: + clear_ok_acl_rules(shared_zone_test_context) + for rs in new_rs: + client.delete_recordset(rs['zoneId'], rs['id'], status=202) + for rs in new_rs: + client.wait_until_recordset_deleted(rs['zoneId'], rs['id']) diff --git a/modules/api/functional_test/live_tests/recordsets/update_recordset_test.py b/modules/api/functional_test/live_tests/recordsets/update_recordset_test.py new file mode 100644 index 0000000000..b6e84574e9 --- /dev/null +++ b/modules/api/functional_test/live_tests/recordsets/update_recordset_test.py @@ -0,0 +1,1931 @@ +import pytest +import copy +from utils import * + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from test_data import TestData +from vinyldns_context import VinylDNSTestContext +import time + + +def test_update_a_with_same_name_as_cname(shared_zone_test_context): + """ + Test that updating a A record fails if the name change conflicts with an existing CNAME name + """ + client = shared_zone_test_context.ok_vinyldns_client + + try: + cname_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'duplicate-test-name', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.' + } + ] + } + + a_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'unique-test-name', + 'type': 'A', + 'ttl': 500, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + + cname_create = client.create_recordset(cname_rs, status=202) + cname_record = client.wait_until_recordset_change_status(cname_create, 'Complete')['recordSet'] + + a_create = client.create_recordset(a_rs, status=202) + a_record = client.wait_until_recordset_change_status(a_create, 'Complete')['recordSet'] + + a_rs_update = copy.deepcopy(a_record) + a_rs_update['name'] = 'duplicate-test-name' + + error = client.update_recordset(a_rs_update, status=409) + assert_that(error, is_('RecordSet with name duplicate-test-name and type CNAME already exists in zone system-test.')) + finally: + delete_result_cname = client.delete_recordset(cname_record['zoneId'], cname_record['id'], status=202) + client.wait_until_recordset_change_status(delete_result_cname, 'Complete') + delete_result_a = client.delete_recordset(a_record['zoneId'], a_record['id'], status=202) + client.wait_until_recordset_change_status(delete_result_a, 'Complete') + + +def test_update_cname_with_same_name_as_another_record(shared_zone_test_context): + """ + Test that updating a CNAME record fails if the name change conflicts with an existing record name + """ + client = shared_zone_test_context.ok_vinyldns_client + + try: + cname_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'unique-test-name', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.' + } + ] + } + + a_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'duplicate-test-name', + 'type': 'A', + 'ttl': 500, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + + cname_create = client.create_recordset(cname_rs, status=202) + cname_record = client.wait_until_recordset_change_status(cname_create, 'Complete')['recordSet'] + + a_create = client.create_recordset(a_rs, status=202) + a_record = client.wait_until_recordset_change_status(a_create, 'Complete')['recordSet'] + + cname_rs_update = copy.deepcopy(cname_record) + cname_rs_update['name'] = 'duplicate-test-name' + + error = client.update_recordset(cname_rs_update, status=409) + assert_that(error, is_('RecordSet with name duplicate-test-name already exists in zone system-test., CNAME record cannot use duplicate name')) + finally: + delete_result_cname = client.delete_recordset(cname_record['zoneId'], cname_record['id'], status=202) + client.wait_until_recordset_change_status(delete_result_cname, 'Complete') + delete_result_a = client.delete_recordset(a_record['zoneId'], a_record['id'], status=202) + client.wait_until_recordset_change_status(delete_result_a, 'Complete') + + +def test_update_cname_with_multiple_records(shared_zone_test_context): + """ + Test that creating a CNAME record set and then updating with multiple records returns an error + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_update_cname_with_multiple_records', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # update the record set, adding another cname record so there are multiple + updated_rs = copy.deepcopy(result_rs) + updated_rs['records'] = [ + { + 'cname': 'cname1.' + }, + { + 'cname': 'cname2.' + } + ] + + errors = client.update_recordset(updated_rs, status=400)['errors'] + assert_that(errors[0], is_("CNAME record sets cannot contain multiple records")) + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +def test_update_cname_with_multiple_records(shared_zone_test_context): + """ + Test that creating a CNAME record set and then updating with multiple records returns an error + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test_update_cname_with_multiple_records', + 'type': 'CNAME', + 'ttl': 500, + 'records': [ + { + 'cname': 'cname1.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # update the record set, adding another cname record so there are multiple + updated_rs = copy.deepcopy(result_rs) + updated_rs['records'] = [ + { + 'cname': 'cname1.' + }, + { + 'cname': 'cname2.' + } + ] + + errors = client.update_recordset(updated_rs, status=400)['errors'] + assert_that(errors[0], is_("CNAME record sets cannot contain multiple records")) + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +def test_update_change_name_success(shared_zone_test_context): + """ + Tests updating a record set and changing the name works + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + new_rs = { + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'test-update-change-name-success-1', + 'type': 'A', + 'ttl': 500, + 'records': [ + { + 'address': '1.1.1.1' + }, + { + 'address': '1.1.1.2' + } + ] + } + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # update the record set, changing the name + updated_rs = copy.deepcopy(result_rs) + updated_rs['name'] = 'test-update-change-name-success-2' + updated_rs['ttl'] = 600 + updated_rs['records'] = [ + { + 'address': '2.2.2.2' + } + ] + + result = client.update_recordset(updated_rs, status=202) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(600)) + assert_that(result_rs['name'], is_('test-update-change-name-success-2')) + assert_that(result_rs['records'][0]['address'], is_('2.2.2.2')) + assert_that(result_rs['records'], has_length(1)) + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +@pytest.mark.parametrize('record_name,test_rs', TestData.FORWARD_RECORDS) +def test_update_recordset_forward_record_types(shared_zone_test_context, record_name, test_rs): + """ + Test updating a record set in a forward zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = dict(test_rs, zoneId=shared_zone_test_context.system_test_zone['id']) + + result = client.create_recordset(new_rs, status=202) + assert_that(result['status'], is_('Pending')) + print str(result) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = result_rs['records'] + + for record in new_rs['records']: + assert_that(records, has_item(has_entries(record))) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # now update + update_rs = result_rs + update_rs['ttl'] = 1000 + + result = client.update_recordset(update_rs, status=202) + assert_that(result['status'], is_('Pending')) + result_rs = result['recordSet'] + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(1000)) + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +@pytest.mark.parametrize('record_name,test_rs', TestData.REVERSE_RECORDS) +def test_reverse_update_reverse_record_types(shared_zone_test_context, record_name, test_rs): + """ + Test updating a record set in a reverse zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = dict(test_rs, zoneId=shared_zone_test_context.ip4_reverse_zone['id']) + + result = client.create_recordset(new_rs, status=202) + assert_that(result['status'], is_('Pending')) + print str(result) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + + records = result_rs['records'] + + for record in new_rs['records']: + assert_that(records, has_item(has_entries(record))) + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # now update + update_rs = result_rs + update_rs['ttl'] = 1000 + + result = client.update_recordset(update_rs, status=202) + assert_that(result['status'], is_('Pending')) + result_rs = result['recordSet'] + + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(1000)) + + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +def test_update_recordset_long_name(shared_zone_test_context): + """ + Test updating a record set where the name is too long + """ + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + + try: + new_rs = { + 'id': 'abc', + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'a', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + result = client.create_recordset(new_rs, status=202) + + result_rs = result['recordSet'] + verify_recordset(result_rs, new_rs) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + update_rs = { + 'id': 'abc', + 'zoneId': shared_zone_test_context.system_test_zone['id'], + 'name': 'a'*256, + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + } + client.update_recordset(update_rs, status=400) + finally: + if result_rs: + result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + if result: + client.wait_until_recordset_change_status(result, 'Complete') + + +def test_user_can_update_record_in_zone_it_owns(shared_zone_test_context): + """ + Test user can update a record that it owns + """ + client = shared_zone_test_context.ok_vinyldns_client + rs = None + try: + rs = client.create_recordset( + { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_user_can_update_record_in_zone_it_owns', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + } + ] + }, status=202 + )['recordSet'] + client.wait_until_recordset_exists(rs['zoneId'], rs['id']) + + rs['ttl'] = rs['ttl'] + 1000 + + result = client.update_recordset(rs, status=202, retries=3) + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(rs['ttl'])) + finally: + if rs: + try: + client.delete_recordset(rs['zoneId'], rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(rs['zoneId'], rs['id']) + finally: + pass + + +def test_update_recordset_no_authorization(shared_zone_test_context): + """ + Test updating a record set without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + rs = { + 'id': '12345', + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_update_recordset_no_authorization', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + client.update_recordset(rs, sign_request=False, status=401) + + +def test_update_recordset_replace_2_records_with_1_different_record(shared_zone_test_context): + """ + Test creating a new record set in an existing zone and then updating that record set to replace the existing + records with one new one + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': 'test_update_recordset_replace_2_records_with_1_different_record', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + verify_recordset(result_rs, new_rs) + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(2)) + assert_that('10.1.1.1', is_in(records)) + assert_that('10.2.2.2', is_in(records)) + + result_rs['ttl'] = 200 + + modified_records = [ + { + 'address': '1.1.1.1' + } + ] + result_rs['records'] = modified_records + + result = client.update_recordset(result_rs, status=202) + assert_that(result['status'], is_('Pending')) + result = client.wait_until_recordset_change_status(result, 'Complete') + + assert_that(result['changeType'], is_('Update')) + assert_that(result['status'], is_('Complete')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + # make sure the update was applied + result_rs = result['recordSet'] + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(1)) + assert_that(records[0], is_('1.1.1.1')) + + # verify that the record exists in the backend dns server + answers = dns_resolve(ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(1)) + assert_that('1.1.1.1', is_in(rdata_strings)) + + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_existing_record_set_add_record(shared_zone_test_context): + """ + Test creating a new record set in an existing zone and then updating that record set to add a record + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': 'test_update_existing_record_set_add_record', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.2.2.2' + } + ] + } + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + verify_recordset(result_rs, new_rs) + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(1)) + assert_that(records[0], is_('10.2.2.2')) + + answers = dns_resolve(ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + print "GOT ANSWERS BACK FOR INITIAL CREATE:" + print str(rdata_strings) + + # Update the record set, adding a new record to the existing one + modified_records = [ + { + 'address': '4.4.4.8' + }, + { + 'address': '10.2.2.2' + } + ] + result_rs['records'] = modified_records + + import json + print "UPDATING RECORD SET, NEW RECORD SET IS..." + print json.dumps(result_rs, indent=3) + + result = client.update_recordset(result_rs, status=202) + assert_that(result['status'], is_('Pending')) + result = client.wait_until_recordset_change_status(result, 'Complete') + + assert_that(result['changeType'], is_('Update')) + assert_that(result['status'], is_('Complete')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + # make sure the update was applied + result_rs = result['recordSet'] + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(2)) + assert_that('10.2.2.2', is_in(records)) + assert_that('4.4.4.8', is_in(records)) + + answers = dns_resolve(ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + print "GOT BACK ANSWERS FOR UPDATE" + print str(rdata_strings) + assert_that(rdata_strings, has_length(2)) + assert_that('10.2.2.2', is_in(rdata_strings)) + assert_that('4.4.4.8', is_in(rdata_strings)) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_existing_record_set_delete_record(shared_zone_test_context): + """ + Test creating a new record set in an existing zone and then updating that record set to delete a record + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': 'test_update_existing_record_set_delete_record', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + }, + { + 'address': '10.3.3.3' + }, + { + 'address': '10.4.4.4' + } + ] + } + result = client.create_recordset(new_rs, status=202) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + verify_recordset(result_rs, new_rs) + + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(4)) + assert_that(records[0], is_('10.1.1.1')) + assert_that(records[1], is_('10.2.2.2')) + assert_that(records[2], is_('10.3.3.3')) + assert_that(records[3], is_('10.4.4.4')) + + answers = dns_resolve(ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(4)) + + # Update the record set, delete three records and leave one + modified_records = [ + { + 'address': '10.2.2.2' + } + ] + result_rs['records'] = modified_records + + result = client.update_recordset(result_rs, status=202) + result = client.wait_until_recordset_change_status(result, 'Complete') + + # make sure the update was applied + result_rs = result['recordSet'] + records = [x['address'] for x in result_rs['records']] + assert_that(records, has_length(1)) + assert_that('10.2.2.2', is_in(records)) + + # do a DNS query + answers = dns_resolve(ok_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + assert_that(rdata_strings, has_length(1)) + assert_that('10.2.2.2', is_in(rdata_strings)) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_ipv4_ptr_recordset_with_verify(shared_zone_test_context): + """ + Test updating an IPv4 PTR record set returns the updated values after complete + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse4_zone = shared_zone_test_context.ip4_reverse_zone + result_rs = None + try: + orig_rs = { + 'zoneId': reverse4_zone['id'], + 'name': '30.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + result = client.create_recordset(orig_rs, status=202) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Updating..." + + new_ptr_target = 'www.vinyldns.' + new_rs = result_rs + print new_rs + new_rs['records'][0]['ptrdname'] = new_ptr_target + print new_rs + result = client.update_recordset(new_rs, status=202) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!updated recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + print result_rs + records = result_rs['records'] + assert_that(records[0]['ptrdname'], is_(new_ptr_target)) + + print "\r\n\r\n!!!verifying recordset in dns backend" + # verify that the record exists in the backend dns server + answers = dns_resolve(reverse4_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + + assert_that(rdata_strings, has_length(1)) + assert_that(rdata_strings[0], is_(new_ptr_target)) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_ipv6_ptr_recordset(shared_zone_test_context): + """ + Test updating an IPv6 PTR record set returns the updated values after complete + """ + client = shared_zone_test_context.ok_vinyldns_client + reverse6_zone = shared_zone_test_context.ip6_reverse_zone + result_rs = None + try: + orig_rs = { + 'zoneId': reverse6_zone['id'], + 'name': '0.6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', + 'type': 'PTR', + 'ttl': 100, + 'records': [ + { + 'ptrdname': 'ftp.vinyldns.' + } + ] + } + result = client.create_recordset(orig_rs, status=202) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!recordset is active! Updating..." + + new_ptr_target = 'www.vinyldns.' + new_rs = result_rs + print new_rs + new_rs['records'][0]['ptrdname'] = new_ptr_target + print new_rs + result = client.update_recordset(new_rs, status=202) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + print "\r\n\r\n!!!updated recordset is active! Verifying..." + + verify_recordset(result_rs, new_rs) + print "\r\n\r\n!!!recordset verified..." + + print result_rs + records = result_rs['records'] + assert_that(records[0]['ptrdname'], is_(new_ptr_target)) + + print "\r\n\r\n!!!verifying recordset in dns backend" + answers = dns_resolve(reverse6_zone, result_rs['name'], result_rs['type']) + rdata_strings = rdata(answers) + assert_that(rdata_strings, has_length(1)) + assert_that(rdata_strings[0], is_(new_ptr_target)) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_recordset_fails_when_changing_name_to_an_existing_name(shared_zone_test_context): + """ + Test creating a new record set fails when an update attempts to change the name of one recordset + to the name of another that already exists + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs_1 = None + result_rs_2 = None + try: + new_rs_1 = { + 'zoneId': ok_zone['id'], + 'name': 'update_recordset_fails_when_changing_name_to_an_existing_name', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result = client.create_recordset(new_rs_1, status=202) + result_rs_1 = result['recordSet'] + result_rs_1 = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + new_rs_2 = { + 'zoneId': ok_zone['id'], + 'name': 'update_recordset_fails_when_changing_name_to_an_existing_name_2', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '2.2.2.2' + }, + { + 'address': '3.3.3.3' + } + ] + } + result = client.create_recordset(new_rs_2, status=202) + result_rs_2 = result['recordSet'] + result_rs_2 = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + # attempt to change the name of the second to the name of the first + result_rs_2['name'] = result_rs_1['name'] + + client.update_recordset(result_rs_2, status=409) + + finally: + if result_rs_1: + delete_result = client.delete_recordset(result_rs_1['zoneId'], result_rs_1['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + if result_rs_2: + delete_result = client.delete_recordset(result_rs_2['zoneId'], result_rs_2['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_recordset_zone_not_found(shared_zone_test_context): + """ + Test updating a record set in a zone that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = None + + try: + new_rs = { + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_update_recordset_zone_not_found', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + result = client.create_recordset(new_rs, status=202) + new_rs = result['recordSet'] + client.wait_until_recordset_exists(new_rs['zoneId'], new_rs['id']) + new_rs['zoneId'] = '1234' + client.update_recordset(new_rs, status=404) + finally: + if new_rs: + try: + client.delete_recordset(shared_zone_test_context.ok_zone['id'], new_rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(shared_zone_test_context.ok_zone['id'], new_rs['id']) + finally: + pass + + +def test_update_recordset_not_found(shared_zone_test_context): + """ + Test updating a record set that doesn't exist should return a 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + new_rs = { + 'id': 'nothere', + 'zoneId': shared_zone_test_context.ok_zone['id'], + 'name': 'test_update_recordset_not_found', + 'type': 'A', + 'ttl': 100, + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + client.update_recordset(new_rs, status=404) + + +def test_at_update_recordset(shared_zone_test_context): + """ + Test creating a new record set with name @ in an existing zone and then updating that recordset with name @ + """ + client = shared_zone_test_context.ok_vinyldns_client + ok_zone = shared_zone_test_context.ok_zone + result_rs = None + try: + new_rs = { + 'zoneId': ok_zone['id'], + 'name': '@', + 'type': 'TXT', + 'ttl': 100, + 'records': [ + { + 'text': 'someText' + } + ] + } + + result = client.create_recordset(new_rs, status=202) + print str(result) + + assert_that(result['changeType'], is_('Create')) + assert_that(result['status'], is_('Pending')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + result_rs = result['recordSet'] + result_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + expected_rs = new_rs + expected_rs['name'] = ok_zone['name'] + verify_recordset(result_rs, expected_rs) + + records = result_rs['records'] + assert_that(records, has_length(1)) + assert_that(records[0]['text'], is_('someText')) + + result_rs['ttl'] = 200 + result_rs['records'][0]['text'] = 'differentText' + + result = client.update_recordset(result_rs, status=202) + assert_that(result['status'], is_('Pending')) + result = client.wait_until_recordset_change_status(result, 'Complete') + + assert_that(result['changeType'], is_('Update')) + assert_that(result['status'], is_('Complete')) + assert_that(result['created'], is_not(none())) + assert_that(result['userId'], is_not(none())) + + # make sure the update was applied + result_rs = result['recordSet'] + records = result_rs['records'] + assert_that(records, has_length(1)) + assert_that(records[0]['text'], is_('differentText')) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_can_update_record_via_user_acl_rule(shared_zone_test_context): + """ + Test user WRITE ACL rule - update + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy') + + result_rs = seed_text_recordset(client, "test_user_can_update_record_via_user_acl_rule", ok_zone) + + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = result_rs['ttl'] + 1000 + + # Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + # add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + # Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_can_update_record_via_group_acl_rule(shared_zone_test_context): + """ + Test group WRITE ACL rule - update + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + acl_rule = generate_acl_rule('Write', groupId=shared_zone_test_context.dummy_group['id']) + try: + result_rs = seed_text_recordset(client, "test_user_can_update_record_via_group_acl_rule", ok_zone) + + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = result_rs['ttl'] + 1000 + + # Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + + # add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + # Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_rule_priority_over_group_acl_rule(shared_zone_test_context): + """ + Test user rule takes priority over group rule + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + group_acl_rule = generate_acl_rule('Read', groupId=shared_zone_test_context.dummy_group['id']) + user_acl_rule = generate_acl_rule('Write', userId='dummy') + + result_rs = seed_text_recordset(client, "test_user_rule_priority_over_group_acl_rule", ok_zone) + + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #add rules + add_ok_acl_rules(shared_zone_test_context, [group_acl_rule, user_acl_rule]) + + #Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=(202, 404)) + client.wait_until_recordset_deleted(result_rs['zoneId'], result_rs['id']) + + +def test_more_restrictive_acl_rule_priority(shared_zone_test_context): + """ + Test more restrictive rule takes priority + """ + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + read_rule = generate_acl_rule('Read', userId='dummy') + write_rule = generate_acl_rule('Write', userId='dummy') + + result_rs = seed_text_recordset(client, "test_more_restrictive_acl_rule_priority", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #add rules + add_ok_acl_rules(shared_zone_test_context, [read_rule, write_rule]) + + #Dummy user cannot update record + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_record_type_success(shared_zone_test_context): + """ + Test a rule on a specific record type applies to that type + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['TXT']) + + result_rs = seed_text_recordset(client, "test_acl_rule_with_record_type_success", ok_zone) + + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = result_rs['ttl'] + 1000 + + z = client.get_zone(ok_zone['id']) + print "this is the zone before we try an update..." + print json.dumps(z, indent=3) + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_cidr_ip4_success(shared_zone_test_context): + """ + Test a rule on a specific record type applies to that type + """ + result_rs = None + ip4_zone = shared_zone_test_context.ip4_reverse_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="172.30.0.0/32") + + result_rs = seed_ptr_recordset(client, "0.0", ip4_zone) + + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ip4_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ip4_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_cidr_ip4_failure(shared_zone_test_context): + """ + Test a rule on a specific record type applies to that type + """ + result_rs = None + ip4_zone = shared_zone_test_context.ip4_reverse_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="172.30.0.0/32") + + result_rs = seed_ptr_recordset(client, "0.1", ip4_zone) + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ip4_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user still cant update record + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ip4_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_cidr_ip6_success(shared_zone_test_context): + """ + Test a rule on a specific record type applies to that type + """ + result_rs = None + ip6_zone = shared_zone_test_context.ip6_reverse_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="fd69:27cc:fe91:0000:0000:0000:0000:0000/127") + + result_rs = seed_ptr_recordset(client, "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", ip6_zone) + + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ip6_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ip6_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_cidr_ip6_failure(shared_zone_test_context): + """ + Test a rule on a specific record type applies to that type + """ + result_rs = None + ip6_zone = shared_zone_test_context.ip6_reverse_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="fd69:27cc:fe91:0000:0000:0000:0000:0000/127") + + result_rs = seed_ptr_recordset(client, "0.0.0.0.0.0.0.0.0.0.0.0.0.0.5.0.0.0.0.0", ip6_zone) + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ip6_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user still cant update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ip6_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_more_restrictive_cidr_ip4_rule_priority(shared_zone_test_context): + """ + Test more restrictive cidr rule takes priority + """ + ip4_zone = shared_zone_test_context.ip4_reverse_zone + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + slash16_rule = generate_acl_rule('Read', userId='dummy', recordTypes=['PTR'], recordMask="172.30.0.0/16") + slash32_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="172.30.0.0/32") + + result_rs = seed_ptr_recordset(client, "0.0", ip4_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #add rules + add_ip4_acl_rules(shared_zone_test_context, [slash16_rule, slash32_rule]) + + #Dummy user can update record + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + finally: + clear_ip4_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_more_restrictive_cidr_ip6_rule_priority(shared_zone_test_context): + """ + Test more restrictive cidr rule takes priority + """ + ip6_zone = shared_zone_test_context.ip6_reverse_zone + client = shared_zone_test_context.ok_vinyldns_client + result_rs = None + try: + slash50_rule = generate_acl_rule('Read', userId='dummy', recordTypes=['PTR'], recordMask="fd69:27cc:fe91:0000:0000:0000:0000:0000/50") + slash100_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="fd69:27cc:fe91:0000:0000:0000:0000:0000/100") + + + result_rs = seed_ptr_recordset(client, "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", ip6_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #add rules + add_ip6_acl_rules(shared_zone_test_context, [slash50_rule, slash100_rule]) + + #Dummy user can update record + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + finally: + clear_ip6_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_mix_of_cidr_ip6_and_acl_rules_priority(shared_zone_test_context): + """ + A and AAAA should have read from mixed rule, PTR should have Write from rule with mask + """ + ip6_zone = shared_zone_test_context.ip6_reverse_zone + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + result_rs_PTR = None + result_rs_A = None + result_rs_AAAA = None + + try: + mixed_type_rule_no_mask = generate_acl_rule('Read', userId='dummy', recordTypes=['PTR','AAAA','A']) + ptr_rule_with_mask = generate_acl_rule('Write', userId='dummy', recordTypes=['PTR'], recordMask="fd69:27cc:fe91:0000:0000:0000:0000:0000/50") + + result_rs_PTR = seed_ptr_recordset(client, "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", ip6_zone) + result_rs_PTR['ttl'] = result_rs_PTR['ttl'] + 1000 + + result_rs_A = seed_text_recordset(client, "test_more_restrictive_acl_rule_priority_1", ok_zone) + result_rs_A['ttl'] = result_rs_A['ttl'] + 1000 + + result_rs_AAAA = seed_text_recordset(client, "test_more_restrictive_acl_rule_priority_2", ok_zone) + result_rs_AAAA['ttl'] = result_rs_AAAA['ttl'] + 1000 + + #add rules + add_ip6_acl_rules(shared_zone_test_context, [mixed_type_rule_no_mask, ptr_rule_with_mask]) + add_ok_acl_rules(shared_zone_test_context, [mixed_type_rule_no_mask, ptr_rule_with_mask]) + + #Dummy user cannot update record for A,AAAA, but can for PTR + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs_PTR, status=202) + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs_A, status=403) + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs_AAAA, status=403) + finally: + clear_ip6_acl_rules(shared_zone_test_context) + clear_ok_acl_rules(shared_zone_test_context) + if result_rs_A: + delete_result = client.delete_recordset(result_rs_A['zoneId'], result_rs_A['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + if result_rs_AAAA: + delete_result = client.delete_recordset(result_rs_AAAA['zoneId'], result_rs_AAAA['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + if result_rs_PTR: + delete_result = client.delete_recordset(result_rs_PTR['zoneId'], result_rs_PTR['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_wrong_record_type(shared_zone_test_context): + """ + Test a rule on a specific record type does not apply to other types + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=['CNAME']) + + result_rs = seed_text_recordset(client, "test_acl_rule_with_wrong_record_type", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user cannot update record + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_empty_acl_record_type_applies_to_all(shared_zone_test_context): + """ + Test an empty record set rule applies to all types + """ + + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', userId='dummy', recordTypes=[]) + + result_rs = seed_text_recordset(client, "test_empty_acl_record_type_applies_to_all", ok_zone) + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = expected_ttl + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403, retries=3) + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_fewer_record_types_prioritized(shared_zone_test_context): + """ + Test a rule on a specific record type takes priority over a group of types + """ + + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule_base = generate_acl_rule('Write', userId='dummy') + acl_rule1 = generate_acl_rule('Write', userId='dummy', recordTypes=['TXT', 'CNAME']) + acl_rule2 = generate_acl_rule('Read', userId='dummy', recordTypes=['TXT']) + + result_rs = seed_text_recordset(client, "test_acl_rule_with_fewer_record_types_prioritized", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + add_ok_acl_rules(shared_zone_test_context, [acl_rule_base]) + + #Dummy user can update record in zone with base rule + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule1, acl_rule2]) + + #Dummy user cannot update record + result_rs['ttl'] = result_rs['ttl'] + 1000 + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_user_over_record_type_priority(shared_zone_test_context): + """ + Test the user priority takes precedence over record type priority + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule_base = generate_acl_rule('Write', userId='dummy') + acl_rule1 = generate_acl_rule('Write', groupId=shared_zone_test_context.dummy_group['id'], recordTypes=['TXT']) + acl_rule2 = generate_acl_rule('Read', userId='dummy', recordTypes=['TXT', 'CNAME']) + + result_rs = seed_text_recordset(client, "test_acl_rule_user_over_record_type_priority", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + add_ok_acl_rules(shared_zone_test_context, [acl_rule_base]) + + #Dummy user can update record in zone with base rule + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule1, acl_rule2]) + + #Dummy user cannot update record + result_rs['ttl'] = result_rs['ttl'] + 1000 + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_record_mask_success(shared_zone_test_context): + """ + Test rule with record mask allows user to update record + """ + + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', groupId=shared_zone_test_context.dummy_group['id'], recordMask='test.*') + + result_rs = seed_text_recordset(client, "test_acl_rule_with_record_mask_success", ok_zone) + expected_ttl = result_rs['ttl'] + 1000 + result_rs['ttl'] = expected_ttl + + #Dummy user cannot update record in zone + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user can update record + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + assert_that(result_rs['ttl'], is_(expected_ttl)) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_record_mask_failure(shared_zone_test_context): + """ + Test rule with unmatching record mask is not applied + """ + + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule = generate_acl_rule('Write', groupId=shared_zone_test_context.dummy_group['id'], recordMask='bad.*') + + result_rs = seed_text_recordset(client, "test_acl_rule_with_record_mask_failure", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + + #Dummy user cannot update record + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_acl_rule_with_defined_mask_prioritized(shared_zone_test_context): + """ + Test a rule on a specific record mask takes priority over All + """ + + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule_base = generate_acl_rule('Write', userId='dummy') + acl_rule1 = generate_acl_rule('Write', userId='dummy', recordMask='.*') + acl_rule2 = generate_acl_rule('Read', userId='dummy', recordMask='test.*') + + result_rs = seed_text_recordset(client, "test_acl_rule_with_defined_mask_prioritized", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + add_ok_acl_rules(shared_zone_test_context, [acl_rule_base]) + + #Dummy user can update record in zone with base rule + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule1, acl_rule2]) + + #Dummy user cannot update record + result_rs['ttl'] = result_rs['ttl'] + 1000 + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_user_rule_over_mask_prioritized(shared_zone_test_context): + """ + Test user/group logic priority over record mask + """ + + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + acl_rule_base = generate_acl_rule('Write', userId='dummy') + acl_rule1 = generate_acl_rule('Write', groupId=shared_zone_test_context.dummy_group['id'], recordMask='test.*') + acl_rule2 = generate_acl_rule('Read', userId='dummy', recordMask='.*') + + result_rs = seed_text_recordset(client, "test_user_rule_over_mask_prioritized", ok_zone) + result_rs['ttl'] = result_rs['ttl'] + 1000 + + add_ok_acl_rules(shared_zone_test_context, [acl_rule_base]) + + #Dummy user can update record in zone with base rule + result = shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=202) + result_rs = shared_zone_test_context.ok_vinyldns_client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + #add rule + add_ok_acl_rules(shared_zone_test_context, [acl_rule1, acl_rule2]) + + #Dummy user cannot update record + result_rs['ttl'] = result_rs['ttl'] + 1000 + shared_zone_test_context.dummy_vinyldns_client.update_recordset(result_rs, status=403) + finally: + clear_ok_acl_rules(shared_zone_test_context) + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_ns_update_for_non_approved_group_fails(shared_zone_test_context): + """ + Tests that someone not in the approved admin group cannot update ns record (only ok group is approved for tests) + """ + + client = shared_zone_test_context.ok_vinyldns_client + not_approved_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.parent_zone + + ns_rs = None + try: + new_rs = { + 'zoneId': zone['id'], + 'name': 'someNS', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + ns_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + changed_rs = ns_rs + changed_rs['ttl'] = changed_rs['ttl'] + 100 + + error = not_approved_client.update_recordset(changed_rs, status=403) + assert_that(error, is_('Do not have permissions to manage NS recordsets, please contact vinyldns-support')) + + finally: + if ns_rs: + client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=(202,404)) + client.wait_until_recordset_deleted(ns_rs['zoneId'], ns_rs['id']) + + +def test_ns_update_for_approved_group_passes(shared_zone_test_context): + """ + Tests that someone in the approved admin group ok-group can update ns record (only ok group is approved for tests) + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + ns_rs = None + + try: + new_rs = { + 'zoneId': zone['id'], + 'name': 'someNS', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + ns_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + changed_rs = ns_rs + changed_rs['ttl'] = changed_rs['ttl'] + 100 + + change_result = client.update_recordset(changed_rs, status=202) + client.wait_until_recordset_change_status(change_result, 'Complete') + + finally: + if ns_rs: + client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=(202,404)) + client.wait_until_recordset_deleted(ns_rs['zoneId'], ns_rs['id']) + + +def test_update_to_dotted_host_fails(shared_zone_test_context): + """ + Tests that a dotted host record set update fails + """ + result_rs = None + ok_zone = shared_zone_test_context.ok_zone + client = shared_zone_test_context.ok_vinyldns_client + try: + result_rs = seed_text_recordset(client, "update_with_dots", ok_zone) + + result_rs['name'] = "update_with.dots" + + error = client.update_recordset(result_rs, status=422) + assert_that(error, is_('Record with name update_with.dots is a dotted host which is illegal in this zone ok.')) + finally: + if result_rs: + delete_result = client.delete_recordset(result_rs['zoneId'], result_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_ns_update_change_ns_name_to_origin_fails(shared_zone_test_context): + """ + Tests that an ns update for origin fails + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + ns_rs = None + + try: + new_rs = { + 'zoneId': zone['id'], + 'name': 'update-change-ns-name-to-origin', + 'type': 'NS', + 'ttl': 38400, + 'records': [ + { + 'nsdname': 'ns1.parent.com.' + } + ] + } + result = client.create_recordset(new_rs, status=202) + ns_rs = client.wait_until_recordset_change_status(result, 'Complete')['recordSet'] + + changed_rs = ns_rs + changed_rs['name'] = "@" + + client.update_recordset(changed_rs, status=409) + + finally: + if ns_rs: + client.delete_recordset(ns_rs['zoneId'], ns_rs['id'], status=(202,404)) + client.wait_until_recordset_deleted(ns_rs['zoneId'], ns_rs['id']) + + +def test_ns_update_existing_ns_origin_fails(shared_zone_test_context): + """ + Tests that an ns update for existing ns origin fails + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + list_results_page = client.list_recordsets(zone['id'], status=200)['recordSets'] + + apex_ns = [item for item in list_results_page if item['type'] == 'NS' and item['name'] in zone['name']][0] + + apex_ns['ttl'] = apex_ns['ttl'] + 100 + + client.update_recordset(apex_ns, status=422) + +def test_update_dotted_a_record_not_apex_fails(shared_zone_test_context): + """ + Test that updating a dotted host name A record set fails. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + dotted_host_rs = { + 'zoneId': zone['id'], + 'name': 'fubu', + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + + create_response = client.create_recordset(dotted_host_rs, status=202) + create_rs = client.wait_until_recordset_change_status(create_response, 'Complete')['recordSet'] + + create_rs['name'] = 'foo.bar' + + try: + error = client.update_recordset(create_rs, status=422) + assert_that(error, is_("Record with name " + create_rs['name'] + " is a dotted host which is illegal " + "in this zone " + zone['name'])) + + finally: + delete_result = client.delete_recordset(zone['id'], create_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + +def test_update_dotted_a_record_apex_succeeds(shared_zone_test_context): + """ + Test that updating an apex A record set containing dots succeeds. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + zone_name = zone['name'] + + apex_rs = { + 'zoneId': zone['id'], + 'name': 'fubu', + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + + create_response = client.create_recordset(apex_rs, status=202) + create_rs = client.wait_until_recordset_change_status(create_response, 'Complete')['recordSet'] + create_rs['name'] = zone_name + + try: + update_response = client.update_recordset(create_rs, status=202) + update_rs = client.wait_until_recordset_change_status(update_response, 'Complete')['recordSet'] + assert_that(update_rs['name'], is_(zone_name)) + + finally: + delete_result = client.delete_recordset(zone['id'], create_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + +def test_update_dotted_a_record_apex_adds_trailing_dot_to_name(shared_zone_test_context): + """ + Test that updating an A record set to apex adds a trailing dot to the name if it is not already in the name. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + zone_name = zone['name'] + + recordset = { + 'zoneId': zone['id'], + 'name': 'silly', + 'type': 'A', + 'ttl': 500, + 'records': [{'address': '127.0.0.1'}] + } + + create_response = client.create_recordset(recordset, status=202) + create_rs = client.wait_until_recordset_change_status(create_response, 'Complete')['recordSet'] + update_rs = create_rs + update_rs['name'] = zone['name'].rstrip('.') + + try: + update_response = client.update_recordset(update_rs, status=202) + updated_rs = client.wait_until_recordset_change_status(update_response, 'Complete')['recordSet'] + assert_that(updated_rs['name'], is_(zone_name)) + + finally: + delete_result = client.delete_recordset(zone['id'], create_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + + +def test_update_dotted_cname_record_apex_fails(shared_zone_test_context): + """ + Test that updating a CNAME record set with record name matching dotted apex returns an error. + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + zone_name = zone['name'].rstrip('.') + + apex_cname_rs = { + 'zoneId': zone['id'], + 'name': 'ygritte', + 'type': 'CNAME', + 'ttl': 500, + 'records': [{'cname': 'got.reference'}] + } + + create_response = client.create_recordset(apex_cname_rs, status=202) + create_rs = client.wait_until_recordset_change_status(create_response, 'Complete')['recordSet'] + + create_rs['name'] = zone_name + + try: + errors = client.update_recordset(create_rs, status=400)['errors'] + assert_that(errors[0],is_("Record name cannot contain '.' with given type")) + + finally: + delete_response = client.delete_recordset(zone['id'],create_rs['id'], status=202)['status'] + client.wait_until_recordset_deleted(delete_response, 'Complete') + +def test_update_succeeds_for_applied_unsynced_record_change(shared_zone_test_context): + """ + Update should succeed if record change is not synced with DNS backend, but has already been applied + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + a_rs = get_recordset_json(zone, 'already-applied-unsynced-update', 'A', [{'address': '1.1.1.1'}, {'address': '2.2.2.2'}]) + + create_rs = {} + + try: + create_response = client.create_recordset(a_rs, status=202) + create_rs = client.wait_until_recordset_change_status(create_response, 'Complete')['recordSet'] + + dns_update(zone, 'already-applied-unsynced-update', 550, 'A', '8.8.8.8') + + updates = create_rs + updates['ttl'] = 550 + updates['records'] = [ + { + 'address': '8.8.8.8' + } + ] + + update_response = client.update_recordset(updates, status=202) + update_rs = client.wait_until_recordset_change_status(update_response, 'Complete')['recordSet'] + + retrieved_rs = client.get_recordset(zone['id'], update_rs['id'])['recordSet'] + verify_recordset(retrieved_rs, updates) + + finally: + try: + delete_result = client.delete_recordset(zone['id'], create_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass + + +def test_update_fails_for_unapplied_unsynced_record_change(shared_zone_test_context): + """ + Update should fail if record change is not synced with DNS backend + """ + + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.parent_zone + + a_rs = get_recordset_json(zone, 'unapplied-unsynced-update', 'A', [{'address': '1.1.1.1'}, {'address': '2.2.2.2'}]) + + create_rs = {} + + try: + create_response = client.create_recordset(a_rs, status=202) + create_rs = client.wait_until_recordset_change_status(create_response, 'Complete')['recordSet'] + + dns_update(zone, 'unapplied-unsynced-update', 550, 'A', '8.8.8.8') + + update_rs = create_rs + update_rs['records'] = [ + { + 'address': '5.5.5.5' + } + ] + update_response = client.update_recordset(update_rs, status=202) + response = client.wait_until_recordset_change_status(update_response, 'Failed') + assert_that(response['systemMessage'], is_("Failed validating update to DNS for change " + response['id'] + + ":" + a_rs['name'] + ": This record set is out of sync with the DNS backend; sync this zone before attempting to update this record set.")) + + finally: + try: + delete_result = client.delete_recordset(zone['id'], create_rs['id'], status=202) + client.wait_until_recordset_change_status(delete_result, 'Complete') + except: + pass diff --git a/modules/api/functional_test/live_tests/shared_zone_test_context.py b/modules/api/functional_test/live_tests/shared_zone_test_context.py new file mode 100644 index 0000000000..db022e9993 --- /dev/null +++ b/modules/api/functional_test/live_tests/shared_zone_test_context.py @@ -0,0 +1,275 @@ +import time +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from hamcrest import * +from utils import * + +class SharedZoneTestContext(object): + """ + Creates multiple zones to test authorization / access to shared zones across users + """ + def __init__(self): + self.ok_vinyldns_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, 'okAccessKey', 'okSecretKey') + self.dummy_vinyldns_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, 'dummyAccessKey', 'dummySecretKey') + + self.dummy_group = None + self.ok_group = None + + self.tear_down() # ensures that the environment is clean before starting + + try: + self.ok_group = self.ok_vinyldns_client.get_group("ok", status=200) + # in theory this shouldn't be needed, but getting 'user is not in group' errors on zone creation + self.confirm_member_in_group(self.ok_vinyldns_client, self.ok_group) + + dummy_group = { + 'name': 'dummy-group', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'dummy'} ], + 'admins': [ { 'id': 'dummy'} ] + } + self.dummy_group = self.dummy_vinyldns_client.create_group(dummy_group, status=200) + # in theory this shouldn't be needed, but getting 'user is not in group' errors on zone creation + self.confirm_member_in_group(self.dummy_vinyldns_client, self.dummy_group) + + ok_zone_change = self.ok_vinyldns_client.create_zone( + { + 'name': 'ok.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.ok_group['id'], + 'connection': { + 'name': 'ok.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'ok.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.ok_zone = ok_zone_change['zone'] + + dummy_zone_change = self.dummy_vinyldns_client.create_zone( + { + 'name': 'dummy.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.dummy_group['id'], + 'connection': { + 'name': 'dummy.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'dummy.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.dummy_zone = dummy_zone_change['zone'] + + ip6_reverse_zone_change = self.ok_vinyldns_client.create_zone( + { + 'name': '1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa.', + 'email': 'test@test.com', + 'shared': True, + 'adminGroupId': self.ok_group['id'], + 'connection': { + 'name': 'ip6.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'ip6.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202 + ) + self.ip6_reverse_zone = ip6_reverse_zone_change['zone'] + + ip4_reverse_zone_change = self.ok_vinyldns_client.create_zone( + { + 'name': '30.172.in-addr.arpa.', + 'email': 'test@test.com', + 'shared': True, + 'adminGroupId': self.ok_group['id'], + 'connection': { + 'name': 'ip4.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'ip4.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202 + ) + self.ip4_reverse_zone = ip4_reverse_zone_change['zone'] + + classless_base_zone_change = self.ok_vinyldns_client.create_zone( + { + 'name': '2.0.192.in-addr.arpa.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.ok_group['id'], + 'connection': { + 'name': 'classless-base.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'classless-base.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202 + ) + self.classless_base_zone = classless_base_zone_change['zone'] + + classless_zone_delegation_change = self.ok_vinyldns_client.create_zone( + { + 'name': '192/30.2.0.192.in-addr.arpa.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.ok_group['id'], + 'connection': { + 'name': 'classless.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'classless.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202 + ) + self.classless_zone_delegation_zone = classless_zone_delegation_change['zone'] + + system_test_zone_change = self.ok_vinyldns_client.create_zone( + { + 'name': 'system-test.', + 'email': 'test@test.com', + 'shared': True, + 'adminGroupId': self.ok_group['id'], + 'connection': { + 'name': 'system-test.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'system-test.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202 + ) + self.system_test_zone = system_test_zone_change['zone'] + + # parent zone gives access to the dummy user, dummy user cannot manage ns records + parent_zone_change = self.ok_vinyldns_client.create_zone( + { + 'name': 'parent.com.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.ok_group['id'], + 'acl': { + 'rules': [ + { + 'accessLevel': 'Delete', + 'description': 'some_test_rule', + 'userId': 'dummy' + } + ] + }, + 'connection': { + 'name': 'parent.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'parent.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.parent_zone = parent_zone_change['zone'] + + # wait until our zones are created + self.ok_vinyldns_client.wait_until_zone_exists(system_test_zone_change) + self.ok_vinyldns_client.wait_until_zone_exists(ok_zone_change) + self.dummy_vinyldns_client.wait_until_zone_exists(dummy_zone_change) + self.ok_vinyldns_client.wait_until_zone_exists(ip6_reverse_zone_change) + self.ok_vinyldns_client.wait_until_zone_exists(ip4_reverse_zone_change) + self.ok_vinyldns_client.wait_until_zone_exists(classless_base_zone_change) + self.ok_vinyldns_client.wait_until_zone_exists(classless_zone_delegation_change) + self.ok_vinyldns_client.wait_until_zone_exists(system_test_zone_change) + self.ok_vinyldns_client.wait_until_zone_exists(parent_zone_change) + + # validate all in there + zones = self.dummy_vinyldns_client.list_zones()['zones'] + assert_that(len(zones), is_(2)) + zones = self.ok_vinyldns_client.list_zones()['zones'] + assert_that(len(zones), is_(7)) + + except: + # teardown if there was any issue in setup + try: + self.tear_down() + except: + pass + raise + + + def tear_down(self): + """ + The ok_vinyldns_client is a zone admin on _all_ the zones. + + We shouldn't have to do any checks now, as zone admins have full rights to all zones, including + deleting all records (even in the old shared model) + """ + clear_zones(self.dummy_vinyldns_client) + clear_zones(self.ok_vinyldns_client) + clear_groups(self.dummy_vinyldns_client) + clear_groups(self.ok_vinyldns_client, exclude=['ok']) + + # reset ok_group + ok_group = { + 'id': 'ok', + 'name': 'ok', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok'} ], + 'admins': [ { 'id': 'ok'} ] + } + self.ok_vinyldns_client.update_group(ok_group['id'], ok_group, status=200) + + def confirm_member_in_group(self, client, group): + retries = 2 + success = group in client.list_all_my_groups(status=200) + while retries >= 0 and not success: + success = group in client.list_all_my_groups(status=200) + time.sleep(.05) + retries -= 1 + assert_that(success, is_(True)) diff --git a/modules/api/functional_test/live_tests/test_data.py b/modules/api/functional_test/live_tests/test_data.py new file mode 100644 index 0000000000..3069d9f2ce --- /dev/null +++ b/modules/api/functional_test/live_tests/test_data.py @@ -0,0 +1,124 @@ +class TestData: + A = { + 'zoneId': None, + 'name': 'test-create-a-ok', + 'type': 'A', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'address': '10.1.1.1' + }, + { + 'address': '10.2.2.2' + } + ] + } + AAAA = { + 'zoneId': None, + 'name': 'test-create-aaaa-ok', + 'type': 'AAAA', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'address': '2001:db8:0:0:0:0:0:3' + }, + { + 'address': '2002:db8:0:0:0:0:0:3' + } + ] + } + CNAME = { + 'zoneId': None, + 'name': 'test-create-cname-ok', + 'type': 'CNAME', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'cname': 'cname.' + } + ] + } + MX = { + 'zoneId': None, + 'name': 'test-create-mx-ok', + 'type': 'MX', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'preference': 100, + 'exchange': 'exchange.' + } + ] + } + PTR = { + 'zoneId': None, + 'name': '10.20', + 'type': 'PTR', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'ptrdname': 'ptr.' + } + ] + } + SPF = { + 'zoneId': None, + 'name': 'test-create-spf-ok', + 'type': 'SPF', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'text': 'spf.' + } + ] + } + SRV = { + 'zoneId': None, + 'name': 'test-create-srv-ok', + 'type': 'SRV', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'priority': 1, + 'weight': 2, + 'port': 8000, + 'target': 'srv.' + } + ] + } + SSHFP = { + 'zoneId': None, + 'name': 'test-create-sshfp-ok', + 'type': 'SSHFP', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'algorithm': 1, + 'type': 2, + 'fingerprint': 'fp' + } + ] + } + TXT = { + 'zoneId': None, + 'name': 'test-create-txt-ok', + 'type': 'TXT', + 'ttl': 100, + 'account': 'foo', + 'records': [ + { + 'text': 'some text' + } + ] + } + RECORDS = [('A', A), ('AAAA', AAAA), ('CNAME', CNAME), ('MX', MX), ('PTR', PTR), ('SPF', SPF), ('SRV', SRV), ('SSHFP', SSHFP), ('TXT', TXT)] + FORWARD_RECORDS = [('A', A), ('AAAA', AAAA), ('CNAME', CNAME), ('MX', MX), ('SPF', SPF), ('SRV', SRV), ('SSHFP', SSHFP), ('TXT', TXT)] + REVERSE_RECORDS = [('CNAME', CNAME), ('PTR', PTR), ('TXT', TXT)] diff --git a/modules/api/functional_test/live_tests/zone_history_context.py b/modules/api/functional_test/live_tests/zone_history_context.py new file mode 100644 index 0000000000..08c2f1656e --- /dev/null +++ b/modules/api/functional_test/live_tests/zone_history_context.py @@ -0,0 +1,169 @@ +import sys +import json + +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from hamcrest import * +from itertools import * +from hamcrest import * +from utils import * +from test_data import TestData + + +class ZoneHistoryContext(object): + """ + Creates a zone with multiple zone changes and record set changes + """ + + def __init__(self): + self.client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, 'history-key', 'history-secret') + self.tear_down() + self.group = None + + group = { + 'name': 'history-group', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'history-id'} ], + 'admins': [ { 'id': 'history-id'} ] + } + + self.group = self.client.create_group(group, status=200) + # in theory this shouldn't be needed, but getting 'user is not in group' errors on zone creation + self.confirm_member_in_group(self.client, self.group) + + zone_change = self.client.create_zone( + { + 'name': 'system-test-history.', + 'email': 'i.changed.this.1.times@history-test.com', + 'shared': True, + 'adminGroupId': self.group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.zone = zone_change['zone'] + + self.client.wait_until_zone_exists(zone_change) + + # change the zone nine times to we have update events in zone change history, ten total changes including creation + for i in range(2,11): + zone_update = dict(self.zone) + zone_update['connection']['key'] = VinylDNSTestContext.dns_key + zone_update['transferConnection']['key'] = VinylDNSTestContext.dns_key + zone_update['email'] = 'i.changed.this.{0}.times@history-test.com'.format(i) + zone_update = self.client.update_zone(zone_update, status=202)['zone'] + + # create some record sets + (achange, a_record) = self.create_recordset(TestData.A) + (aaaachange, aaaa_record) = self.create_recordset(TestData.AAAA) + (cnamechange, cname_record) = self.create_recordset(TestData.CNAME) + + # wait here for all the record sets to be created + self.client.wait_until_recordset_exists(a_record['zoneId'], a_record['id']) + self.client.wait_until_recordset_exists(aaaa_record['zoneId'], aaaa_record['id']) + self.client.wait_until_recordset_exists(cname_record['zoneId'], cname_record['id']) + + # update the record sets + a_record_update = dict(a_record) + a_record_update['ttl'] += 100 + a_record_update['records'][0]['address'] = '9.9.9.9' + (achange, a_record_update) = self.update_recordset(a_record_update) + + aaaa_record_update = dict(aaaa_record) + aaaa_record_update['ttl'] += 100 + aaaa_record_update['records'][0]['address'] = '2003:db8:0:0:0:0:0:4' + (aaaachange, aaaa_record_update) = self.update_recordset(aaaa_record_update) + + cname_record_update = dict(cname_record) + cname_record_update['ttl'] += 100 + cname_record_update['records'][0]['cname'] = 'changed-cname.' + (cnamechange, cname_record_update) = self.update_recordset(cname_record_update) + + self.client.wait_until_recordset_change_status(achange, 'Complete') + self.client.wait_until_recordset_change_status(aaaachange, 'Complete') + self.client.wait_until_recordset_change_status(cnamechange, 'Complete') + + + # delete the recordsets + self.delete_recordset(a_record) + self.delete_recordset(aaaa_record) + self.delete_recordset(cname_record) + + self.client.wait_until_recordset_deleted(a_record['zoneId'], a_record['id']) + self.client.wait_until_recordset_deleted(aaaa_record['zoneId'], aaaa_record['id']) + self.client.wait_until_recordset_deleted(cname_record['zoneId'], cname_record['id']) + + + # the resulting context should contain all of the parts so it makes it simple to test + self.results = { + 'zone': self.zone, + 'zoneUpdate': zone_update, + 'creates': [a_record, aaaa_record, cname_record], + 'updates': [a_record_update, aaaa_record_update, cname_record_update] + } + + # finalizer called by py.test when the simulation is torn down + def tear_down(self): + self.clear_zones() + self.clear_group() + + + def clear_group(self): + groups = self.client.list_all_my_groups() + group_ids = map(lambda x: x['id'], groups) + + for group_id in group_ids: + self.client.delete_group(group_id, status=200) + + + def clear_zones(self): + # Get the groups for the ok user + groups = self.client.list_all_my_groups() + group_ids = map(lambda x: x['id'], groups) + + zones = self.client.list_zones()['zones'] + + # we only want to delete zones that the ok user "owns" + zones_to_delete = filter(lambda x: (x['adminGroupId'] in group_ids) or (x['account'] in group_ids), zones) + zone_names_to_delete = map(lambda x: x['name'], zones_to_delete) + + zoneids_to_delete = map(lambda x: x['id'], zones_to_delete) + + self.client.abandon_zones(zoneids_to_delete) + + + def create_recordset(self, rs): + rs['zoneId'] = self.zone['id'] + result = self.client.create_recordset(rs, status=202) + return result, result['recordSet'] + + + def update_recordset(self, rs): + rs['zoneId'] = self.zone['id'] + result = self.client.update_recordset(rs, status=202) + return result, result['recordSet'] + + + def delete_recordset(self, rs): + result = self.client.delete_recordset(self.zone['id'], rs['id'], status=202) + return result, result['recordSet'] + + + def confirm_member_in_group(self, client, group): + retries = 2 + success = group in client.list_all_my_groups(status=200) + while retries >= 0 and not success: + success = group in client.list_all_my_groups(status=200) + time.sleep(.05) + retries -= 1 + assert_that(success, is_(True)) diff --git a/modules/api/functional_test/live_tests/zones/create_zone_test.py b/modules/api/functional_test/live_tests/zones/create_zone_test.py new file mode 100644 index 0000000000..5a6570b4e4 --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/create_zone_test.py @@ -0,0 +1,473 @@ +import pytest +import uuid + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * + +records_in_dns = [ + {'name': 'one-time.', + 'type': 'SOA', + 'records': [{u'mname': u'172.17.42.1.', + u'rname': u'admin.test.com.', + u'retry': 3600, + u'refresh': 10800, + u'minimum': 38400, + u'expire': 604800, + u'serial': 1439234395}]}, + {'name': u'one-time.', + 'type': u'NS', + 'records': [{u'nsdname': u'172.17.42.1.'}]}, + {'name': u'jenkins', + 'type': u'A', + 'records': [{u'address': u'10.1.1.1'}]}, + {'name': u'foo', + 'type': u'A', + 'records': [{u'address': u'2.2.2.2'}]}, + {'name': u'test', + 'type': u'A', + 'records': [{u'address': u'3.3.3.3'}, {u'address': u'4.4.4.4'}]}, + {'name': u'one-time.', + 'type': u'A', + 'records': [{u'address': u'5.5.5.5'}]}, + {'name': u'already-exists', + 'type': u'A', + 'records': [{u'address': u'6.6.6.6'}]}] + +def test_create_zone_success(shared_zone_test_context): + """ + Test successfully creating a zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_change_status(result, 'Synced') + + get_result = client.get_zone(result_zone['id']) + + get_zone = get_result['zone'] + assert_that(get_zone['name'], is_(zone['name']+'.')) + assert_that(get_zone['email'], is_(zone['email'])) + assert_that(get_zone['adminGroupId'], is_(zone['adminGroupId'])) + assert_that(get_zone['latestSync'], is_not(none())) + assert_that(get_zone['status'], is_('Active')) + + # confirm that the recordsets in DNS have been saved in vinyldns + recordsets = client.list_recordsets(result_zone['id'])['recordSets'] + + assert_that(len(recordsets), is_(7)) + for rs in recordsets: + small_rs = dict((k, rs[k]) for k in ['name', 'type', 'records']) + small_rs['records'] = sorted(small_rs['records']) + assert_that(records_in_dns, has_item(small_rs)) + + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + + +@pytest.mark.skip_production +def test_create_zone_without_transfer_connection_leaves_it_empty(shared_zone_test_context): + """ + Test that creating a zone with a valid connection but without a transfer connection leaves the transfer connection empty + """ + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + get_result = client.get_zone(result_zone['id']) + + get_zone = get_result['zone'] + assert_that(get_zone['name'], is_(zone['name']+'.')) + assert_that(get_zone['email'], is_(zone['email'])) + assert_that(get_zone['adminGroupId'], is_(zone['adminGroupId'])) + + assert_that(get_zone, is_not(has_key('transferConnection'))) + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + + +def test_create_zone_fails_no_authorization(shared_zone_test_context): + """ + Test creating a new zone without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone = { + 'name': str(uuid.uuid4()), + 'email': 'test@test.com', + } + client.create_zone(zone, sign_request=False, status=401) + + +def test_create_missing_zone_data(shared_zone_test_context): + """ + Test that creating a zone without providing necessary data (name and email) returns errors + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone = { + 'random_key': 'some_value', + 'another_key': 'meaningless_data' + } + + errors = client.create_zone(zone, status=400)['errors'] + assert_that(errors, contains_inanyorder('Missing Zone.name', 'Missing Zone.email')) + + +def test_create_invalid_zone_data(shared_zone_test_context): + """ + Test that creating a zone with invalid data returns errors + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'test.zone.invalid.' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'status': 'invalid_status' + } + + errors = client.create_zone(zone, status=400)['errors'] + assert_that(errors, contains_inanyorder('Invalid ZoneStatus')) + + +def test_create_zone_with_connection_failure(shared_zone_test_context): + """ + Test creating a new zone with a an invalid key and connection info fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'one-time.' + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'connection': { + 'name': zone_name, + 'keyName': zone_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + client.create_zone(zone, status=400) + + +def test_create_zone_returns_409_if_already_exists(shared_zone_test_context): + """ + Test creating a zone returns a 409 Conflict if the zone name already exists + """ + create_conflict = dict(shared_zone_test_context.ok_zone) + create_conflict['connection']['key'] = VinylDNSTestContext.dns_key # necessary because we encrypt the key + create_conflict['transferConnection']['key'] = VinylDNSTestContext.dns_key + + shared_zone_test_context.ok_vinyldns_client.create_zone(create_conflict, status=409) + + +def test_create_zone_returns_400_for_invalid_data(shared_zone_test_context): + """ + Test creating a zone returns a 400 if the request body is invalid + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone = { + 'jim': 'bob', + 'hey': 'you' + } + client.create_zone(zone, status=400) + + +@pytest.mark.skip_production +def test_create_zone_no_connection_uses_defaults(shared_zone_test_context): + + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'] + } + + try: + zone_change = client.create_zone(zone, status=202) + zone = zone_change['zone'] + client.wait_until_zone_exists(zone_change) + + # Check response from create + assert_that(zone['name'], is_(zone_name+'.')) + print "'connection' not in zone = " + 'connection' not in zone + + assert_that('connection' not in zone)#KeyError: 'connection' + assert_that('transferConnection' not in zone) + + # Check that it was internally stored correctly using GET + zone_get = client.get_zone(zone['id'])['zone'] + assert_that(zone_get['name'], is_(zone_name+'.')) + assert_that('connection' not in zone_get) + assert_that('transferConnection' not in zone_get) + + finally: + if 'id' in zone: + client.abandon_zones([zone['id']], status=202) + + +def test_zone_connection_only(shared_zone_test_context): + + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + expected_connection = { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + + try: + zone_change = client.create_zone(zone, status=202) + zone = zone_change['zone'] + client.wait_until_zone_exists(zone_change) + + # Check response from create + assert_that(zone['name'], is_(zone_name+'.')) + assert_that(zone['connection']['name'], is_(expected_connection['name'])) + assert_that(zone['connection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['connection']['primaryServer'], is_(expected_connection['primaryServer'])) + assert_that(zone['transferConnection']['name'], is_(expected_connection['name'])) + assert_that(zone['transferConnection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['transferConnection']['primaryServer'], is_(expected_connection['primaryServer'])) + + # Check that it was internally stored correctly using GET + zone_get = client.get_zone(zone['id'])['zone'] + assert_that(zone_get['name'], is_(zone_name+'.')) + assert_that(zone['connection']['name'], is_(expected_connection['name'])) + assert_that(zone['connection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['connection']['primaryServer'], is_(expected_connection['primaryServer'])) + assert_that(zone['transferConnection']['name'], is_(expected_connection['name'])) + assert_that(zone['transferConnection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['transferConnection']['primaryServer'], is_(expected_connection['primaryServer'])) + + finally: + if 'id' in zone: + client.abandon_zones([zone['id']], status=202) + + +def test_zone_bad_connection(shared_zone_test_context): + + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'connection': { + 'name': zone_name, + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': 'somebadkey', + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + client.create_zone(zone, status=400) + + +def test_zone_bad_transfer_connection(shared_zone_test_context): + + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'connection': { + 'name': zone_name, + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': zone_name, + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': "bad", + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + client.create_zone(zone, status=400) + + +def test_zone_transfer_connection(shared_zone_test_context): + + client = shared_zone_test_context.ok_vinyldns_client + + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': zone_name, + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': zone_name, + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + expected_connection = { + 'name': zone_name, + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + + try: + zone_change = client.create_zone(zone, status=202) + zone = zone_change['zone'] + client.wait_until_zone_exists(zone_change) + + # Check response from create + assert_that(zone['name'], is_(zone_name+'.')) + assert_that(zone['connection']['name'], is_(expected_connection['name'])) + assert_that(zone['connection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['connection']['primaryServer'], is_(expected_connection['primaryServer'])) + assert_that(zone['transferConnection']['name'], is_(expected_connection['name'])) + assert_that(zone['transferConnection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['transferConnection']['primaryServer'], is_(expected_connection['primaryServer'])) + + # Check that it was internally stored correctly using GET + zone_get = client.get_zone(zone['id'])['zone'] + assert_that(zone_get['name'], is_(zone_name+'.')) + assert_that(zone['connection']['name'], is_(expected_connection['name'])) + assert_that(zone['connection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['connection']['primaryServer'], is_(expected_connection['primaryServer'])) + assert_that(zone['transferConnection']['name'], is_(expected_connection['name'])) + assert_that(zone['transferConnection']['keyName'], is_(expected_connection['keyName'])) + assert_that(zone['transferConnection']['primaryServer'], is_(expected_connection['primaryServer'])) + + finally: + if 'id' in zone: + client.abandon_zones([zone['id']], status=202) + + +def test_user_cannot_create_zone_with_nonmember_admin_group(shared_zone_test_context): + """ + Test user cannot create a zone with an admin group they are not a member of + """ + zone = { + 'name': 'one-time.', + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.dummy_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + shared_zone_test_context.ok_vinyldns_client.create_zone(zone, status=400) + + +def test_user_cannot_create_zone_with_failed_validations(shared_zone_test_context): + """ + Test that a user cannot create a zone that has invalid zone data + """ + zone = { + 'name': 'invalid-zone.', + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + + result = shared_zone_test_context.ok_vinyldns_client.create_zone(zone, status=400) + import json + print json.dumps(result, indent=4) + assert_that(result['errors'], contains_inanyorder( + contains_string("not-approved.thing.com. is not an approved name server") + )) diff --git a/modules/api/functional_test/live_tests/zones/delete_zone_test.py b/modules/api/functional_test/live_tests/zones/delete_zone_test.py new file mode 100644 index 0000000000..04962cc5f0 --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/delete_zone_test.py @@ -0,0 +1,106 @@ +import pytest +import uuid + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * + + +def test_delete_zone_success(shared_zone_test_context): + """ + Test deleting a zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + client.delete_zone(result_zone['id'], status=202) + client.wait_until_zone_deleted(result_zone['id']) + + client.get_zone(result_zone['id'], status=404) + result_zone = None + + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + + +def test_delete_zone_twice(shared_zone_test_context): + """ + Test deleting a zone with deleted status returns 404 + """ + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + client.delete_zone(result_zone['id'], status=202) + client.wait_until_zone_deleted(result_zone['id']) + + client.delete_zone(result_zone['id'], status=404) + result_zone = None + + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + + +def test_delete_zone_returns_404_if_zone_not_found(shared_zone_test_context): + """ + Test deleting a zone returns a 404 if the zone was not found + """ + client = shared_zone_test_context.ok_vinyldns_client + client.delete_zone('nothere', status=404) + + +def test_delete_zone_no_authorization(shared_zone_test_context): + """ + Test deleting a zone without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + + client.delete_zone('1234', sign_request=False, status=401) diff --git a/modules/api/functional_test/live_tests/zones/get_zone_test.py b/modules/api/functional_test/live_tests/zones/get_zone_test.py new file mode 100644 index 0000000000..2f6aa1e298 --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/get_zone_test.py @@ -0,0 +1,77 @@ +import pytest +import uuid + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * + + +def test_get_zone_by_id(shared_zone_test_context): + """ + Test get an existing zone by id + """ + client = shared_zone_test_context.ok_vinyldns_client + + result = client.get_zone(shared_zone_test_context.system_test_zone['id'], status=200) + retrieved = result['zone'] + + assert_that(retrieved['id'], is_(shared_zone_test_context.system_test_zone['id'])) + assert_that(retrieved['adminGroupName'], is_(shared_zone_test_context.ok_group['name'])) + + +def test_get_zone_fails_without_access(shared_zone_test_context): + """ + Test get an existing zone by id without access + """ + client = shared_zone_test_context.dummy_vinyldns_client + + client.get_zone(shared_zone_test_context.ok_zone['id'], status=403) + + +def test_get_zone_returns_404_when_not_found(shared_zone_test_context): + """ + Test get an existing zone returns a 404 when the zone is not found + """ + client = shared_zone_test_context.ok_vinyldns_client + + client.get_zone(str(uuid.uuid4()), status=404) + + +def test_get_zone_by_id_no_authorization(shared_zone_test_context): + """ + Test get an existing zone by id without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + client.get_zone('123456', sign_request=False, status=401) + + +def test_get_zone_includes_acl_display_name(shared_zone_test_context): + """ + Test get an existing zone with acl rules + """ + + client = shared_zone_test_context.ok_vinyldns_client + + user_acl_rule = generate_acl_rule('Write', userId='ok', recordTypes = []) + group_acl_rule = generate_acl_rule('Write', groupId=shared_zone_test_context.ok_group['id'], recordTypes = []) + bad_acl_rule = generate_acl_rule('Write', userId='badId', recordTypes = []) + + client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], user_acl_rule, status=202) + client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], group_acl_rule, status=202) + client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], bad_acl_rule, status=202) + + result = client.get_zone(shared_zone_test_context.system_test_zone['id'], status=200) + retrieved = result['zone'] + + assert_that(retrieved['id'], is_(shared_zone_test_context.system_test_zone['id'])) + assert_that(retrieved['adminGroupName'], is_(shared_zone_test_context.ok_group['name'])) + + acl = retrieved['acl']['rules'] + + user_acl_rule['displayName'] = 'ok' + group_acl_rule['displayName'] = shared_zone_test_context.ok_group['name'] + + assert_that(acl, has_item(user_acl_rule)) + assert_that(acl, has_item(group_acl_rule)) + assert_that(len(acl), is_(2)) diff --git a/modules/api/functional_test/live_tests/zones/list_zone_changes_test.py b/modules/api/functional_test/live_tests/zones/list_zone_changes_test.py new file mode 100644 index 0000000000..535757257d --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/list_zone_changes_test.py @@ -0,0 +1,134 @@ +from hamcrest import * +from utils import * +from vinyldns_python import VinylDNSClient + + +def check_zone_changes_page_accuracy(results, expected_first_change, expected_num_results): + assert_that(len(results), is_(expected_num_results)) + change_num = expected_first_change + for change in results: + change_email = 'i.changed.this.{0}.times@history-test.com'.format(change_num) + assert_that(change['zone']['email'], is_(change_email)) + # should return changes in reverse order (most recent 1st) + change_num-=1 + + +def check_zone_changes_responses(response, zoneId=True, zoneChanges=True, nextId=True, startFrom=True, maxItems=True): + assert_that(response, has_key('zoneId')) if zoneId else assert_that(response, is_not(has_key('zoneId'))) + assert_that(response, has_key('zoneChanges')) if zoneChanges else assert_that(response, is_not(has_key('zoneChanges'))) + assert_that(response, has_key('nextId')) if nextId else assert_that(response, is_not(has_key('nextId'))) + assert_that(response, has_key('startFrom')) if startFrom else assert_that(response, is_not(has_key('startFrom'))) + assert_that(response, has_key('maxItems')) if maxItems else assert_that(response, is_not(has_key('maxItems'))) + + +def test_list_zone_changes_no_authorization(shared_zone_test_context): + """ + Test that list zone changes without authorization fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + client.list_zone_changes('12345', sign_request=False, status=401) + + +def test_list_zone_changes_member_auth_success(shared_zone_test_context): + """ + Test list zone changes succeeds with membership auth for member of admin group + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.ok_zone + client.list_zone_changes(zone['id'], status=200) + + +def test_list_zone_changes_member_auth_no_access(shared_zone_test_context): + """ + Test list zone changes fails for user not in admin group with no acl rules + """ + client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.ok_zone + client.list_zone_changes(zone['id'], status=403) + + +def test_list_zone_changes_member_auth_with_acl(shared_zone_test_context): + """ + Test list zone changes succeeds for user with acl rules + """ + zone = shared_zone_test_context.ok_zone + acl_rule = generate_acl_rule('Write', userId='dummy') + try: + client = shared_zone_test_context.dummy_vinyldns_client + + client.list_zone_changes(zone['id'], status=403) + add_ok_acl_rules(shared_zone_test_context, [acl_rule]) + client.list_zone_changes(zone['id'], status=200) + finally: + clear_ok_acl_rules(shared_zone_test_context) + + +def test_list_zone_changes_no_start(zone_history_context): + """ + Test getting all zone changes on one page (max items will default to default value) + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + response = client.list_zone_changes(original_zone['id'], start_from=None) + + check_zone_changes_page_accuracy(response['zoneChanges'], expected_first_change=10, expected_num_results=10) + check_zone_changes_responses(response, startFrom=False, nextId=False) + + +def test_list_zone_changes_paging(zone_history_context): + """ + Test paging for zone changes can use previous nextId as start key of next page + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + response_1 = client.list_zone_changes(original_zone['id'], start_from=None, max_items=3) + response_2 = client.list_zone_changes(original_zone['id'], start_from=response_1['nextId'], max_items=3) + response_3 = client.list_zone_changes(original_zone['id'], start_from=response_2['nextId'], max_items=3) + + check_zone_changes_page_accuracy(response_1['zoneChanges'], expected_first_change=10, expected_num_results=3) + check_zone_changes_page_accuracy(response_2['zoneChanges'], expected_first_change=7, expected_num_results=3) + check_zone_changes_page_accuracy(response_3['zoneChanges'], expected_first_change=4, expected_num_results=3) + + check_zone_changes_responses(response_1, startFrom=False) + check_zone_changes_responses(response_2) + check_zone_changes_responses(response_3) + + +def test_list_zone_changes_exhausted(zone_history_context): + """ + Test next id is none when zone changes are exhausted + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + response = client.list_zone_changes(original_zone['id'], start_from=None, max_items=11) + check_zone_changes_page_accuracy(response['zoneChanges'], expected_first_change=10, expected_num_results=10) + check_zone_changes_responses(response, startFrom=False, nextId=False) + + +def test_list_zone_changes_default_max_items(zone_history_context): + """ + Test default max items is 100 + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + response = client.list_zone_changes(original_zone['id'], start_from=None, max_items=None) + assert_that(response['maxItems'], is_(100)) + check_zone_changes_responses(response, startFrom=None, nextId=None) + + +def test_list_zone_changes_max_items_boundaries(zone_history_context): + """ + Test 0 < max_items <= 100 + """ + client = zone_history_context.client + original_zone = zone_history_context.results['zone'] + + too_large = client.list_zone_changes(original_zone['id'], start_from=None, max_items=101, status=400) + too_small = client.list_zone_changes(original_zone['id'], start_from=None, max_items=0, status=400) + + assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive")) + assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive")) diff --git a/modules/api/functional_test/live_tests/zones/list_zones_test.py b/modules/api/functional_test/live_tests/zones/list_zones_test.py new file mode 100644 index 0000000000..444d5cc259 --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/list_zones_test.py @@ -0,0 +1,292 @@ +import pytest +import uuid + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * + + +class ListZonesTestContext(object): + def __init__(self): + self.client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, 'listZonesAccessKey', 'listZonesSecretKey') + self.tear_down() # ensures that the environment is clean before starting + + try: + group = { + 'name': 'list-zones-group', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'list-zones-user'} ], + 'admins': [ { 'id': 'list-zones-user'} ] + } + + self.list_zones_group = self.client.create_group(group, status=200) + + search_zone_1_change = self.client.create_zone( + { + 'name': 'list-zones-test-searched-1.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.list_zones_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.search_zone_1 = search_zone_1_change['zone'] + + search_zone_2_change = self.client.create_zone( + { + 'name': 'list-zones-test-searched-2.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.list_zones_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.search_zone_2 = search_zone_2_change['zone'] + + + search_zone_3_change = self.client.create_zone( + { + 'name': 'list-zones-test-searched-3.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.list_zones_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.search_zone_3 = search_zone_3_change['zone'] + + non_search_zone_1_change = self.client.create_zone( + { + 'name': 'list-zones-test-unfiltered-1.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.list_zones_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.non_search_zone_1 = non_search_zone_1_change['zone'] + + non_search_zone_2_change = self.client.create_zone( + { + 'name': 'list-zones-test-unfiltered-2.', + 'email': 'test@test.com', + 'shared': False, + 'adminGroupId': self.list_zones_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202) + self.non_search_zone_2 = non_search_zone_2_change['zone'] + + self.zone_ids = [self.search_zone_1['id'], self.search_zone_2['id'], self.search_zone_3['id'], self.non_search_zone_1['id'], self.non_search_zone_2['id']] + zone_changes = [search_zone_1_change, search_zone_2_change, search_zone_3_change, non_search_zone_1_change, non_search_zone_2_change] + for change in zone_changes: + self.client.wait_until_zone_exists(change) + except: + # teardown if there was any issue in setup + try: + self.tear_down() + except: + pass + raise + + def tear_down(self): + clear_zones(self.client) + clear_groups(self.client) + + +@pytest.fixture(scope="module") +def list_zones_context(request): + ctx = ListZonesTestContext() + + def fin(): + ctx.tear_down() + + request.addfinalizer(fin) + + return ctx + +def test_list_zones_success(list_zones_context): + """ + Test that we can retrieve a list of zones + """ + result = list_zones_context.client.list_zones(status=200) + retrieved = result['zones'] + + assert_that(retrieved, has_length(5)) + assert_that(retrieved, has_item(has_entry('name', 'list-zones-test-searched-1.'))) + assert_that(retrieved, has_item(has_entry('adminGroupName', 'list-zones-group'))) + + +def test_list_zones_max_items_100(list_zones_context): + """ + Test that the default max items for a list zones request is 100 + """ + result = list_zones_context.client.list_zones(status=200) + assert_that(result['maxItems'], is_(100)) + + +def test_list_zones_invalid_max_items_fails(list_zones_context): + """ + Test that passing in an invalid value for max items fails + """ + errors = list_zones_context.client.list_zones(max_items=700, status=400) + assert_that(errors, contains_string("maxItems was 700, maxItems must be between 0 and 100")) + + +def test_list_zones_no_authorization(list_zones_context): + """ + Test that we cannot retrieve a list of zones without authorization + """ + list_zones_context.client.list_zones(sign_request=False, status=401) + + +def test_list_zones_no_search_first_page(list_zones_context): + """ + Test that the first page of listing zones returns correctly when no name filter is provided + """ + result = list_zones_context.client.list_zones(max_items=3) + zones = result['zones'] + + assert_that(zones, has_length(3)) + assert_that(zones[0]['name'], is_('list-zones-test-searched-1.')) + assert_that(zones[1]['name'], is_('list-zones-test-searched-2.')) + assert_that(zones[2]['name'], is_('list-zones-test-searched-3.')) + + assert_that(result['nextId'], is_(3)) + assert_that(result['maxItems'], is_(3)) + assert_that(result, is_not(has_key('startFrom'))) + assert_that(result, is_not(has_key('nameFilter'))) + + +def test_list_zones_no_search_second_page(list_zones_context): + """ + Test that the second page of listing zones returns correctly when no name filter is provided + """ + result = list_zones_context.client.list_zones(start_from=2, max_items=2, status=200) + zones = result['zones'] + + assert_that(zones, has_length(2)) + assert_that(zones[0]['name'], is_('list-zones-test-searched-3.')) + assert_that(zones[1]['name'], is_('list-zones-test-unfiltered-1.')) + + assert_that(result['nextId'], is_(4)) + assert_that(result['maxItems'], is_(2)) + assert_that(result['startFrom'], is_(2)) + assert_that(result, is_not(has_key('nameFilter'))) + + +def test_list_zones_no_search_last_page(list_zones_context): + """ + Test that the last page of listing zones returns correctly when no name filter is provided + """ + result = list_zones_context.client.list_zones(start_from=3, max_items=4, status=200) + zones = result['zones'] + + assert_that(zones, has_length(2)) + assert_that(zones[0]['name'], is_('list-zones-test-unfiltered-1.')) + assert_that(zones[1]['name'], is_('list-zones-test-unfiltered-2.')) + + assert_that(result, is_not(has_key('nextId'))) + assert_that(result['maxItems'], is_(4)) + assert_that(result['startFrom'], is_(3)) + assert_that(result, is_not(has_key('nameFilter'))) + + +def test_list_zones_with_search_first_page(list_zones_context): + """ + Test that the first page of listing zones returns correctly when a name filter is provided + """ + result = list_zones_context.client.list_zones(name_filter='searched', max_items=2, status=200) + zones = result['zones'] + + assert_that(zones, has_length(2)) + assert_that(zones[0]['name'], is_('list-zones-test-searched-1.')) + assert_that(zones[1]['name'], is_('list-zones-test-searched-2.')) + + assert_that(result['nextId'], is_(2)) + assert_that(result['maxItems'], is_(2)) + assert_that(result['nameFilter'], is_('searched')) + assert_that(result, is_not(has_key('startFrom'))) + + +def test_list_zones_with_no_results(list_zones_context): + """ + Test that the response is formed correctly when no results are found + """ + result = list_zones_context.client.list_zones(name_filter='this-wont-be-found', max_items=2, status=200) + zones = result['zones'] + + assert_that(zones, has_length(0)) + + assert_that(result['maxItems'], is_(2)) + assert_that(result['nameFilter'], is_('this-wont-be-found')) + assert_that(result, is_not(has_key('startFrom'))) + assert_that(result, is_not(has_key('nextId'))) + + +def test_list_zones_with_search_last_page(list_zones_context): + """ + Test that the second page of listing zones returns correctly when a name filter is provided + """ + result = list_zones_context.client.list_zones(name_filter='searched', start_from=2, max_items=2, status=200) + zones = result['zones'] + + assert_that(zones, has_length(1)) + assert_that(zones[0]['name'], is_('list-zones-test-searched-3.')) + + assert_that(result, is_not(has_key('nextId'))) + assert_that(result['maxItems'], is_(2)) + assert_that(result['nameFilter'], is_('searched')) + assert_that(result['startFrom'], is_(2)) diff --git a/modules/api/functional_test/live_tests/zones/sync_zone_test.py b/modules/api/functional_test/live_tests/zones/sync_zone_test.py new file mode 100644 index 0000000000..34365d582c --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/sync_zone_test.py @@ -0,0 +1,206 @@ +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * +import time + +records_in_dns = [ + {'name': 'sync-test.', + 'type': 'SOA', + 'records': [{u'mname': u'172.17.42.1.', + u'rname': u'admin.test.com.', + u'retry': 3600, + u'refresh': 10800, + u'minimum': 38400, + u'expire': 604800, + u'serial': 1439234395}]}, + {'name': u'sync-test.', + 'type': u'NS', + 'records': [{u'nsdname': u'172.17.42.1.'}]}, + {'name': u'jenkins', + 'type': u'A', + 'records': [{u'address': u'10.1.1.1'}]}, + {'name': u'foo', + 'type': u'A', + 'records': [{u'address': u'2.2.2.2'}]}, + {'name': u'test', + 'type': u'A', + 'records': [{u'address': u'3.3.3.3'}, {u'address': u'4.4.4.4'}]}, + {'name': u'sync-test.', + 'type': u'A', + 'records': [{u'address': u'5.5.5.5'}]}, + {'name': u'already-exists', + 'type': u'A', + 'records': [{u'address': u'6.6.6.6'}]}, + {'name': u'fqdn', + 'type': u'A', + 'records': [{u'address': u'7.7.7.7'}]}, + {'name': u'_sip._tcp', + 'type': u'SRV', + 'records': [{u'priority': 10, u'weight': 60, u'port': 5060, u'target': u'foo.sync-test.'}]}, + {'name': u'existing.dotted', + 'type': u'A', + 'records': [{u'address': u'9.9.9.9'}]}] + +records_post_update = [ + {'name': 'sync-test.', + 'type': 'SOA', + 'records': [{u'mname': u'172.17.42.1.', + u'rname': u'admin.test.com.', + u'retry': 3600, + u'refresh': 10800, + u'minimum': 38400, + u'expire': 604800, + u'serial': 0}]}, + {'name': u'sync-test.', + 'type': u'NS', + 'records': [{u'nsdname': u'172.17.42.1.'}]}, + {'name': u'foo', + 'type': u'A', + 'records': [{u'address': u'1.2.3.4'}]}, + {'name': u'test', + 'type': u'A', + 'records': [{u'address': u'3.3.3.3'}, {u'address': u'4.4.4.4'}]}, + {'name': u'sync-test.', + 'type': u'A', + 'records': [{u'address': u'5.5.5.5'}]}, + {'name': u'already-exists', + 'type': u'A', + 'records': [{u'address': u'6.6.6.6'}]}, + {'name': u'newrs', + 'type': u'A', + 'records': [{u'address': u'2.3.4.5'}]}, + {'name': u'fqdn', + 'type': u'A', + 'records': [{u'address': u'7.7.7.7'}]}, + {'name': u'_sip._tcp', + 'type': u'SRV', + 'records': [{u'priority': 10, u'weight': 60, u'port': 5060, u'target': u'foo.sync-test.'}]}, + {'name': u'existing.dotted', + 'type': u'A', + 'records': [{u'address': u'9.9.9.9'}]}, + {'name': u'dott.ed', + 'type': u'A', + 'records': [{u'address': u'6.7.8.9'}]}] + + +@pytest.mark.skip_production +def test_sync_zone_success(shared_zone_test_context): + """ + Test syncing a zone + """ + client = shared_zone_test_context.ok_vinyldns_client + zone_name = 'sync-test' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + try: + zone_change = client.create_zone(zone, status=202) + zone = zone_change['zone'] + client.wait_until_zone_exists(zone_change) + client.wait_until_zone_change_status(zone_change, 'Synced') + + time.sleep(.5) + + # confirm zone has been synced + get_result = client.get_zone(zone['id']) + synced_zone = get_result['zone'] + latest_sync = synced_zone['latestSync'] + assert_that(latest_sync, is_not(none())) + + # confirm that the recordsets in DNS have been saved in vinyldns + recordsets = client.list_recordsets(zone['id'])['recordSets'] + + assert_that(len(recordsets), is_(10)) + for rs in recordsets: + small_rs = dict((k, rs[k]) for k in ['name', 'type', 'records']) + small_rs['records'] = sorted(small_rs['records']) + if small_rs['type'] == 'SOA': + assert_that(small_rs['name'], is_('sync-test.')) + else: + assert_that(records_in_dns, has_item(small_rs)) + + # make changes to the dns backend + dns_update(zone, 'foo', 38400, 'A', '1.2.3.4') + dns_add(zone, 'newrs', 38400, 'A', '2.3.4.5') + dns_delete(zone, 'jenkins', 'A') + + # add unknown this should not be synced + dns_add(zone, 'dnametest', 38400, 'DNAME', 'test.com.') + + # add a dotted host, this should be synced, so we will have 10 records ( +1 ) + dns_add(zone, 'dott.ed', 38400, 'A', '6.7.8.9') + + # wait for next sync + time.sleep(10) + + # sync again + change = client.sync_zone(zone['id'], status=202) + client.wait_until_zone_change_status(change, 'Synced') + + # confirm cannot again sync without waiting + client.sync_zone(zone['id'], status=403) + + # validate zone + get_result = client.get_zone(zone['id']) + synced_zone = get_result['zone'] + assert_that(synced_zone['latestSync'], is_not(latest_sync)) + assert_that(synced_zone['status'], is_('Active')) + assert_that(synced_zone['updated'], is_not(none())) + + # confirm that the updated recordsets in DNS have been saved in vinyldns + recordsets = client.list_recordsets(zone['id'])['recordSets'] + assert_that(len(recordsets), is_(11)) + for rs in recordsets: + small_rs = dict((k, rs[k]) for k in ['name', 'type', 'records']) + small_rs['records'] = sorted(small_rs['records']) + if small_rs['type'] == 'SOA': + small_rs['records'][0]['serial'] = 0 + # records_post_update does not contain dnametest + assert_that(records_post_update, has_item(small_rs)) + + changes = client.list_recordset_changes(zone['id']) + for c in changes['recordSetChanges']: + assert_that(c['systemMessage'], is_('Change applied via zone sync')) + + for rs in recordsets: + # confirm that we cannot update the dotted host if the name is the same + if rs['name'] == 'dott.ed': + attempt_update = rs + attempt_update['ttl'] = attempt_update['ttl'] + 100 + errors = client.update_recordset(attempt_update, status=422) + assert_that(errors, is_("Record with name " + rs['name'] + " is a dotted host which is illegal " + "in this zone " + zone_name + ".")) + + # we should be able to delete the record + client.delete_recordset(rs['zoneId'], rs['id'], status=202) + client.wait_until_recordset_deleted(rs['zoneId'], rs['id']) + if rs['name'] == "example.dotted": + # confirm that we can modify the example dotted + good_update = rs + good_update['name'] = "example-dotted" + change = client.update_recordset(good_update, status=202) + client.wait_until_recordset_change_status(change, 'Complete') + + finally: + if 'id' in zone: + dns_update(zone, 'foo', 38400, 'A', '2.2.2.2') + dns_delete(zone, 'newrs', 'A') + dns_add(zone, 'jenkins', 38400, 'A', '10.1.1.1') + dns_delete(zone, 'example-dotted', 'A') + client.abandon_zones([zone['id']], status=202) diff --git a/modules/api/functional_test/live_tests/zones/update_zone_test.py b/modules/api/functional_test/live_tests/zones/update_zone_test.py new file mode 100644 index 0000000000..40bb28cf1e --- /dev/null +++ b/modules/api/functional_test/live_tests/zones/update_zone_test.py @@ -0,0 +1,821 @@ +import pytest +import uuid + +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from utils import * + + +def test_update_zone_success(shared_zone_test_context): + """ + Test updating a zone + """ + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time' + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-updated-by-updatezn', + 'userId': 'ok', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + result_zone['email'] = 'foo@bar.com' + result_zone['acl']['rules'] = [acl_rule] + update_result = client.update_zone(result_zone, status=202) + client.wait_until_zone_change_status(update_result, 'Synced') + + assert_that(update_result['changeType'], is_('Update')) + assert_that(update_result['userId'], is_('ok')) + assert_that(update_result, has_key('created')) + + get_result = client.get_zone(result_zone['id']) + + uz = get_result['zone'] + assert_that(uz['email'], is_('foo@bar.com')) + assert_that(uz['updated'], is_not(none())) + + acl = uz['acl'] + verify_acl_rule_is_present_once(acl_rule, acl) + + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + +def test_update_bad_acl_fails(shared_zone_test_context): + """ + Test that updating a zone with a bad ACL rule fails + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.ok_zone + + acl_bad_regex = { + 'accessLevel': 'Read', + 'description': 'test-acl-updated-by-updatezn-bad', + 'userId': 'ok', + 'recordMask': '*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + + zone['acl']['rules'] = [acl_bad_regex] + + client.update_zone(zone, status=400) + + +def test_update_acl_no_group_or_user_fails(shared_zone_test_context): + """ + Test that updating a zone with an ACL with no user/group fails + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.ok_zone + + bad_acl = { + 'accessLevel': 'Read', + 'description': 'test-acl-updated-by-updatezn-bad-ids', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + + zone['acl']['rules'] = [bad_acl] + + client.update_zone(zone, status=400) + + + +def test_update_missing_zone_data(shared_zone_test_context): + """ + Test that updating a zone without providing necessary data returns errors and fails the update + """ + + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time.' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + update_zone = { + 'id': result_zone['id'], + 'name': result_zone['name'], + 'random_key': 'some_value', + 'another_key': 'meaningless_data' + } + + errors = client.update_zone(update_zone, status=400)['errors'] + assert_that(errors, contains_inanyorder('Missing Zone.email')) + + # Check that the failed update didn't go through + zone_get = client.get_zone(result_zone['id'])['zone'] + assert_that(zone_get['name'], is_(zone_name)) + + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + + +def test_update_invalid_zone_data(shared_zone_test_context): + """ + Test that creating a zone with invalid data returns errors and fails the update + """ + client = shared_zone_test_context.ok_vinyldns_client + result_zone = None + try: + zone_name = 'one-time.' + + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.ok_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + result = client.create_zone(zone, status=202) + result_zone = result['zone'] + client.wait_until_zone_exists(result) + + update_zone = { + 'id': result_zone['id'], + 'name': result_zone['name'], + 'email': 'test@test.com', + 'status': 'invalid_status' + } + + errors = client.update_zone(update_zone, status=400)['errors'] + assert_that(errors, contains_inanyorder('Invalid ZoneStatus')) + + # Check that the failed update didn't go through + zone_get = client.get_zone(result_zone['id'])['zone'] + assert_that(zone_get['name'], is_(zone_name)) + + finally: + if result_zone: + client.abandon_zones([result_zone['id']], status=202) + + +def test_update_zone_returns_404_if_zone_not_found(shared_zone_test_context): + """ + Test updating a zone returns a 404 if the zone was not found + """ + client = shared_zone_test_context.ok_vinyldns_client + zone = { + 'name': 'one-time.', + 'email': 'test@test.com', + 'id': 'nothere', + 'connection': { + 'name': 'old-shared.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'old-shared.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + client.update_zone(zone, status=404) + + +def test_create_acl_group_rule_success(shared_zone_test_context): + """ + Test creating an acl rule successfully + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-group-id', + 'groupId': shared_zone_test_context.ok_group['id'], + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # This is async, we get a zone change back + acl = result['zone']['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + # make sure that our acl rule appears on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + acl = zone['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + +def test_create_acl_user_rule_success(shared_zone_test_context): + """ + Test creating an acl rule successfully + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': 'ok', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # This is async, we get a zone change back + acl = result['zone']['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + # make sure that our acl rule appears on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + acl = zone['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + +def test_create_acl_user_rule_invalid_regex_failure(shared_zone_test_context): + """ + Test creating an acl rule with an invalid regex mask fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': '789', + 'recordMask': 'x{5,-3}', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + + errors = client.add_zone_acl_rule(shared_zone_test_context.system_test_zone['id'], acl_rule, status=400) + assert_that(errors,contains_string("record mask x{5,-3} is an invalid regex")) + + +def test_create_acl_user_rule_invalid_cidr_failure(shared_zone_test_context): + """ + Test creating an acl rule with an invalid cidr mask fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': '789', + 'recordMask': '10.0.0.0/50', + 'recordTypes': ['PTR'] + } + + errors = client.add_zone_acl_rule(shared_zone_test_context.ip4_reverse_zone['id'], acl_rule, status=400) + assert_that(errors,contains_string("PTR types must have no mask or a valid CIDR mask: IPv4 mask must be between 0 and 32")) + + +def test_create_acl_user_rule_valid_cidr_success(shared_zone_test_context): + """ + Test creating an acl rule with a valid cidr mask passes + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': 'ok', + 'recordMask': '10.0.0.0/20', + 'recordTypes': ['PTR'] + } + + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.ip4_reverse_zone['id'], acl_rule, status=202) + + # This is async, we get a zone change back + acl = result['zone']['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + # make sure that our acl rule appears on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + acl = zone['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + +def test_create_acl_user_rule_multiple_cidr_failure(shared_zone_test_context): + """ + Test creating an acl rule with multiple record types including PTR and a cidr mask fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': '789', + 'recordMask': '10.0.0.0/20', + 'recordTypes': ['PTR','A','AAAA'] + } + + errors = client.add_zone_acl_rule(shared_zone_test_context.ip4_reverse_zone['id'], acl_rule, status=400) + assert_that(errors,contains_string("Multiple record types including PTR must have no mask")) + + +def test_create_acl_user_rule_multiple_none_success(shared_zone_test_context): + """ + Test creating an acl rule with multiple record types and no mask passes + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': 'ok', + 'recordTypes': ['PTR','A','AAAA'] + } + + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.ip4_reverse_zone['id'], acl_rule, status=202) + + # This is async, we get a zone change back + acl = result['zone']['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + # make sure that our acl rule appears on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + acl = zone['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + +def test_create_acl_user_rule_multiple_non_cidr_failure(shared_zone_test_context): + """ + Test creating an acl rule with multiple record types including PTR and non cidr mask fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-user-id', + 'userId': '789', + 'recordMask': 'www-*', + 'recordTypes': ['PTR','A','AAAA'] + } + + errors = client.add_zone_acl_rule(shared_zone_test_context.ip4_reverse_zone['id'], acl_rule, status=400) + assert_that(errors,contains_string("Multiple record types including PTR must have no mask")) + + +def test_create_acl_idempotent(shared_zone_test_context): + """ + Test creating the same acl rule multiple times results in only one rule added + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Write', + 'description': 'test-acl-idempotent', + 'userId': 'ok', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result1 = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + result2 = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + result3 = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + zone = client.get_zone(shared_zone_test_context.system_test_zone['id'])['zone'] + + acl = zone['acl'] + + # we should only have one rule that we created + verify_acl_rule_is_present_once(acl_rule, acl) + + +def test_delete_acl_group_rule_success(shared_zone_test_context): + """ + Test deleting an acl rule successfully + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-delete-group-id', + 'groupId': shared_zone_test_context.ok_group['id'], + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # make sure that our acl rule appears on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + acl = zone['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + # delete the rule + result = client.delete_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # make sure that our acl is not on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + verify_acl_rule_is_not_present(acl_rule, zone['acl']) + + +def test_delete_acl_user_rule_success(shared_zone_test_context): + """ + Test deleting an acl rule successfully + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-delete-user-id', + 'userId': 'ok', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # make sure that our acl rule appears on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + acl = zone['acl'] + + verify_acl_rule_is_present_once(acl_rule, acl) + + # delete the rule + result = client.delete_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # make sure that our acl is not on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + verify_acl_rule_is_not_present(acl_rule, zone['acl']) + + +def test_delete_non_existent_acl_rule_success(shared_zone_test_context): + """ + Test deleting an acl rule that doesn't exist still returns successfully + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test-acl-delete-non-existent-user-id', + 'userId': '789', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + # delete the rule + result = client.delete_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + # make sure that our acl is not on the zone + zone = client.get_zone(result['zone']['id'])['zone'] + + verify_acl_rule_is_not_present(acl_rule, zone['acl']) + + +def test_delete_acl_idempotent(shared_zone_test_context): + """ + Test deleting the same acl rule multiple times results in only one rule remomved + """ + client = shared_zone_test_context.ok_vinyldns_client + + acl_rule = { + 'accessLevel': 'Write', + 'description': 'test-delete-acl-idempotent', + 'userId': 'ok', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result = client.add_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + zone = client.get_zone(shared_zone_test_context.system_test_zone['id'])['zone'] + + acl = zone['acl'] + + # we should only have one rule that we created + verify_acl_rule_is_present_once(acl_rule, acl) + + result1 = client.delete_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + result2 = client.delete_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + result3 = client.delete_zone_acl_rule_with_wait(shared_zone_test_context.system_test_zone['id'], acl_rule, status=202) + + zone = client.get_zone(result['zone']['id'])['zone'] + + verify_acl_rule_is_not_present(acl_rule, zone['acl']) + + +def test_delete_acl_removes_permissions(shared_zone_test_context): + """ + Test that a user (who previously had permissions to view a zone via acl rules) can not view the zone once + the acl rule is deleted + """ + + ok_client = shared_zone_test_context.ok_vinyldns_client # ok adds and deletes acl rule + dummy_client = shared_zone_test_context.dummy_vinyldns_client # dummy should not be able to see ok_zone once acl rule is deleted + ok_zone = ok_client.get_zone(shared_zone_test_context.ok_zone['id'])['zone'] + + ok_view = ok_client.list_zones()['zones'] + assert_that(ok_view, has_item(ok_zone)) # ok can see ok_zone + + # verify dummy cannot see ok_zone + dummy_view = dummy_client.list_zones()['zones'] + assert_that(dummy_view, is_not(has_item(ok_zone))) # cannot view zone + + # add acl rule + acl_rule = { + 'accessLevel': 'Read', + 'description': 'test_delete_acl_removes_permissions', + 'userId': 'dummy', # give dummy permission to see ok_zone + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + result = ok_client.add_zone_acl_rule_with_wait(shared_zone_test_context.ok_zone['id'], acl_rule, status=202) + ok_zone = ok_client.get_zone(shared_zone_test_context.ok_zone['id'])['zone'] + verify_acl_rule_is_present_once(acl_rule, ok_zone['acl']) + + ok_view = ok_client.list_zones()['zones'] + assert_that(ok_view, has_item(ok_zone)) # ok can still see ok_zone + + # verify dummy can see ok_zone + dummy_view = dummy_client.list_zones()['zones'] + assert_that(dummy_view, has_item(ok_zone)) # can view zone + + # delete acl rule + result = ok_client.delete_zone_acl_rule_with_wait(shared_zone_test_context.ok_zone['id'], acl_rule, status=202) + ok_zone = ok_client.get_zone(shared_zone_test_context.ok_zone['id'])['zone'] + verify_acl_rule_is_not_present(acl_rule, ok_zone['acl']) + + ok_view = ok_client.list_zones()['zones'] + assert_that(ok_view, has_item(ok_zone)) # ok can still see ok_zone + + # verify dummy can not see ok_zone + dummy_view = dummy_client.list_zones()['zones'] + assert_that(dummy_view, is_not(has_item(ok_zone))) # cannot view zone + + +def test_update_reverse_v4_zone(shared_zone_test_context): + """ + Test updating a reverse IPv4 zone + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone = shared_zone_test_context.ip4_reverse_zone + zone['email'] = 'update-test@bar.com' + + import json + print json.dumps(zone, indent=4) + update_result = client.update_zone(zone, status=202) + client.wait_until_zone_change_status(update_result, 'Synced') + + assert_that(update_result['changeType'], is_('Update')) + assert_that(update_result['userId'], is_('ok')) + assert_that(update_result, has_key('created')) + + get_result = client.get_zone(zone['id']) + + uz = get_result['zone'] + assert_that(uz['email'], is_('update-test@bar.com')) + assert_that(uz['updated'], is_not(none())) + + + +def test_update_reverse_v6_zone(shared_zone_test_context): + """ + Test updating a reverse IPv6 zone + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone = shared_zone_test_context.ip6_reverse_zone + zone['email'] = 'update-test@bar.com' + + update_result = client.update_zone(zone, status=202) + client.wait_until_zone_change_status(update_result, 'Synced') + + assert_that(update_result['changeType'], is_('Update')) + assert_that(update_result['userId'], is_('ok')) + assert_that(update_result, has_key('created')) + + get_result = client.get_zone(zone['id']) + + uz = get_result['zone'] + assert_that(uz['email'], is_('update-test@bar.com')) + assert_that(uz['updated'], is_not(none())) + + +def test_activate_reverse_v4_zone_with_bad_key_fails(shared_zone_test_context): + """ + Test activating a reverse IPv4 zone when using a bad tsig key fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + update = dict(shared_zone_test_context.ip4_reverse_zone) + update['connection']['key'] = 'f00sn+4G2ldMn0q1CV3vsg==' + client.update_zone(update, status=400) + + +def test_activate_reverse_v6_zone_with_bad_key_fails(shared_zone_test_context): + """ + Test activating a reverse IPv6 zone with an invalid key fails + """ + client = shared_zone_test_context.ok_vinyldns_client + + update = dict(shared_zone_test_context.ip6_reverse_zone) + update['connection']['key'] = 'f00sn+4G2ldMn0q1CV3vsg==' + client.update_zone(update, status=400) + + +def test_user_cannot_update_zone_to_nonexisting_admin_group(shared_zone_test_context): + """ + Test user cannot update a zone adminGroupId to a group that does not exist + """ + + zone_update = shared_zone_test_context.ok_zone + zone_update['adminGroupId'] = "some-bad-id" + zone_update['connection']['key'] = VinylDNSTestContext.dns_key + + shared_zone_test_context.ok_vinyldns_client.update_zone(zone_update, status=400) + + +def test_user_can_update_zone_to_another_admin_group(shared_zone_test_context): + """ + Test user can update a zone with an admin group they are a member of + """ + #dummy is member, not admin + + client = shared_zone_test_context.dummy_vinyldns_client + group = None + + try: + result = client.create_zone( + { + 'name': 'one-time.', + 'email': 'test@test.com', + 'adminGroupId': shared_zone_test_context.dummy_group['id'], + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + }, status=202 + ) + zone = result['zone'] + client.wait_until_zone_exists(result) + + import json + print json.dumps(zone, indent=3) + + new_joint_group = { + 'name': 'new-ok-group', + 'email': 'test@test.com', + 'description': 'this is a description', + 'members': [ { 'id': 'ok', 'id': 'dummy'} ], + 'admins': [ { 'id': 'ok'} ] + } + + group = client.create_group(new_joint_group, status=200) + + #changing the zone + zone_update = dict(zone) + zone_update['adminGroupId'] = group['id'] + + result = client.update_zone(zone_update, status=202) + client.wait_until_zone_change_status(result, 'Synced') + finally: + if zone: + client.delete_zone(zone['id'], status=202) + client.wait_until_zone_deleted(zone['id']) + if group: + shared_zone_test_context.ok_vinyldns_client.delete_group(group['id'], status=(200, 404)) + + + +def test_user_cannot_update_zone_to_nonmember_admin_group(shared_zone_test_context): + """ + Test user cannot update a zone adminGroupId to a group they are not a member of + """ + + zone_update = shared_zone_test_context.ok_zone + zone_update['adminGroupId'] = shared_zone_test_context.dummy_group['id'] + zone_update['connection']['key'] = VinylDNSTestContext.dns_key + + shared_zone_test_context.ok_vinyldns_client.update_zone(zone_update, status=400) + + +def test_user_cannot_update_zone_to_nonexisting_admin_group(shared_zone_test_context): + """ + Test user cannot update a zone adminGroupId to a group that does not exist + """ + + zone_update = shared_zone_test_context.ok_zone + zone_update['adminGroupId'] = "some-bad-id" + zone_update['connection']['key'] = VinylDNSTestContext.dns_key + + shared_zone_test_context.ok_vinyldns_client.update_zone(zone_update, status=400) + + +def test_acl_rule_missing_access_level(shared_zone_test_context): + """ + Tests that missing the access level when creating an acl rule returns a 400 + """ + client = shared_zone_test_context.ok_vinyldns_client + acl_rule = { + 'description': 'test-acl-no-access-level', + 'groupId': '456', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + errors = client.add_zone_acl_rule(shared_zone_test_context.system_test_zone['id'], acl_rule, status=400)['errors'] + assert_that(errors, has_length(1)) + assert_that(errors, contains_inanyorder('Missing ACLRule.accessLevel')) + + +def test_acl_rule_both_user_and_group(shared_zone_test_context): + """ + Tests that including the user id and the group id when creating an acl rule returns a 400 + """ + client = shared_zone_test_context.ok_vinyldns_client + acl_rule = { + 'accessLevel': 'Read', + 'userId': '789', + 'groupId': '456', + 'description': 'test-acl-no-user-or-group-level', + 'recordMask': 'www-*', + 'recordTypes': ['A', 'AAAA', 'CNAME'] + } + errors = client.add_zone_acl_rule(shared_zone_test_context.system_test_zone['id'], acl_rule, status=400)['errors'] + assert_that(errors, has_length(1)) + assert_that(errors, contains_inanyorder('Cannot specify both a userId and a groupId')) + + +def test_update_zone_no_authorization(shared_zone_test_context): + """ + Test updating a zone without authorization + """ + client = shared_zone_test_context.ok_vinyldns_client + + zone = { + 'id': '12345', + 'name': str(uuid.uuid4()), + 'email': 'test@test.com', + } + + client.update_zone(zone, sign_request=False, status=401) diff --git a/modules/api/functional_test/perf_tests/uat_sync_test.py b/modules/api/functional_test/perf_tests/uat_sync_test.py new file mode 100644 index 0000000000..9e7b90c378 --- /dev/null +++ b/modules/api/functional_test/perf_tests/uat_sync_test.py @@ -0,0 +1,63 @@ +from hamcrest import * +from vinyldns_client import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +import time + +def test_sync_zone_success(): + """ + Test syncing a zone + """ + zone_name = 'small' + client = VinylDNSClient() + + zones = client.list_zones()['zones'] + zone = [z for z in zones if z['name'] == zone_name + "."] + + lastLatestSync = [] + new = True + if zone: + zone = zone[0] + lastLatestSync = zone['latestSync'] + new = False + + else: + # create zone if it doesnt exist + zone = { + 'name': zone_name, + 'email': 'test@test.com', + 'connection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + }, + 'transferConnection': { + 'name': 'vinyldns.', + 'keyName': VinylDNSTestContext.dns_key_name, + 'key': VinylDNSTestContext.dns_key, + 'primaryServer': VinylDNSTestContext.dns_ip + } + } + zone_change = client.create_zone(zone, status=202) + zone = zone_change['zone'] + client.wait_until_zone_exists(zone_change) + + zone_id = zone['id'] + + # run sync + change = client.sync_zone(zone_id, status=202) + + # brief wait for zone status change. Can't use getZoneHistory here to check on the changeset itself, + # the action times out (presumably also querying the same record change table that the sync itself + # is interacting with) + time.sleep(0.5) + client.wait_until_zone_status(zone_id, 'Active') + + # confirm zone has been updated + get_result = client.get_zone(zone_id) + synced_zone = get_result['zone'] + latestSync = synced_zone['latestSync'] + assert_that(synced_zone['updated'], is_not(none())) + assert_that(latestSync, is_not(none())) + if not new: + assert_that(latestSync, is_not(lastLatestSync)) diff --git a/modules/api/functional_test/pytest.ini b/modules/api/functional_test/pytest.ini new file mode 100644 index 0000000000..3e7692550a --- /dev/null +++ b/modules/api/functional_test/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +norecursedirs=.virtualenv eggs +addopts = -rfesxX --capture=sys --junitxml=../target/pytest_reports/pytest.xml --durations=30 diff --git a/modules/api/functional_test/requirements.txt b/modules/api/functional_test/requirements.txt new file mode 100644 index 0000000000..89e9b498bb --- /dev/null +++ b/modules/api/functional_test/requirements.txt @@ -0,0 +1,14 @@ +# requirements.txt v1.0 +# --------------------- +# Add project specific python requirements to this file. +# Do not commit them in the project! +# Make sure they exist on our corporate PyPi server. + +pyhamcrest==1.8.0 +pytz>=2014 +pytest==2.6.4 +mock==1.0.1 +dnspython==1.14.0 +boto==2.48.0 +future==0.16.0 +requests==2.19.1 \ No newline at end of file diff --git a/modules/api/functional_test/run.py b/modules/api/functional_test/run.py new file mode 100755 index 0000000000..74b8cb0f95 --- /dev/null +++ b/modules/api/functional_test/run.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +import os +import sys + +basedir = os.path.dirname(os.path.realpath(__file__)) +vedir = os.path.join(basedir, '.virtualenv') +os.system('./bootstrap.sh') + +activate_virtualenv = os.path.join(vedir, 'bin', 'activate_this.py') +print('Activating virtualenv at ' + activate_virtualenv) + +report_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../target/pytest_reports') +if not os.path.exists(report_dir): + os.system('mkdir -p ' + report_dir) + +execfile(activate_virtualenv, dict(__file__=activate_virtualenv)) + +import pytest + +result = 1 + +result = pytest.main(list(sys.argv[1:])) + +sys.exit(result) + + diff --git a/modules/api/functional_test/utils.py b/modules/api/functional_test/utils.py new file mode 100644 index 0000000000..e0a68296f8 --- /dev/null +++ b/modules/api/functional_test/utils.py @@ -0,0 +1,532 @@ +import sys +import pytest +import uuid +import json +import dns.query +import dns.tsigkeyring +import dns.update + +from utils import * +from hamcrest import * +from vinyldns_python import VinylDNSClient +from vinyldns_context import VinylDNSTestContext +from test_data import TestData +from dns.resolver import * +import copy + + +def verify_recordset(actual, expected): + """ + Runs basic assertions on the recordset to ensure that actual matches the expected + """ + assert_that(actual['name'], is_(expected['name'])) + assert_that(actual['zoneId'], is_(expected['zoneId'])) + assert_that(actual['type'], is_(expected['type'])) + assert_that(actual['ttl'], is_(expected['ttl'])) + assert_that(actual, has_key('created')) + assert_that(actual['status'], is_not(none())) + assert_that(actual['id'], is_not(none())) + actual_records = [json.dumps(x) for x in actual['records']] + expected_records = [json.dumps(x) for x in expected['records']] + for expected_record in expected_records: + assert_that(actual_records, has_item(expected_record)) + + +def gen_zone(): + """ + Generates a random zone + """ + return { + 'name': str(uuid.uuid4())+'.', + 'email': 'test@test.com', + 'adminGroupId': 'test-group-id' + } + + +def verify_acl_rule_is_present_once(rule, acl): + def match(acl_rule): + # remove displayName if it exists (allows for aclRule and aclRuleInfo comparison) + acl_rule.pop('displayName', None) + return acl_rule == rule + + matches = filter(match, acl['rules']) + assert_that(matches, has_length(1), 'Did not find exactly one match for acl rule') + + +def verify_acl_rule_is_not_present(rule, acl): + def match(acl_rule): + return acl_rule != rule + + matches = filter(match, acl['rules']) + assert_that(matches, has_length(len(acl['rules'])), 'ACL Rule was found but should not have been present') + + +def rdata(dns_answers): + """ + Converts the answers from a dns python query to a sequence of string containing the rdata + :param dns_answers: the results of running the dns_resolve utility function + :return: a sequence containing the rdata sections for each record in the answers + """ + rdata_strings = [] + if dns_answers: + rdata_strings = [x['rdata'] for x in dns_answers] + + return rdata_strings + + +def dns_server_port(zone): + """ + Parses the server and port based on the connection info on the zone + :param zone: a populated zone model + :return: a tuple (host, port), port is an int + """ + name_server = zone['connection']['primaryServer'] + name_server_port = 53 + if ':' in name_server: + parts = name_server.split(':') + name_server = parts[0] + name_server_port = int(parts[1]) + + return name_server, name_server_port + + +def dns_do_command(zone, record_name, record_type, command, ttl=0, rdata=""): + """ + Helper for dns add, update, delete + """ + keyring = dns.tsigkeyring.from_text({ + zone['connection']['keyName']: VinylDNSTestContext.dns_key + }) + + name_server, name_server_port = dns_server_port(zone) + + fqdn = record_name + "." + zone['name'] + + print "updating " + fqdn + " to have data " + rdata + + update = dns.update.Update(zone['name'], keyring=keyring) + if (command == 'add'): + update.add(fqdn, ttl, record_type, rdata) + elif (command == 'update'): + update.replace(fqdn, ttl, record_type, rdata) + elif (command == 'delete'): + update.delete(fqdn, record_type) + + response = dns.query.udp(update, name_server, port=name_server_port, ignore_unexpected=True) + return response + + +def dns_update(zone, record_name, ttl, record_type, rdata): + """ + Issues a DNS update to the backend server + :param zone: a populated zone model + :param record_name: the name of the record to update + :param ttl: the ttl value of the record + :param record_type: the type of record being updated + :param rdata: the rdata string + :return: + """ + return dns_do_command(zone, record_name, record_type, "update", ttl, rdata) + + +def dns_delete(zone, record_name, record_type): + """ + Issues a DNS delete to the backend server + :param zone: a populated zone model + :param record_name: the name of the record to delete + :param record_type: the type of record being delete + :return: + """ + return dns_do_command(zone, record_name, record_type, "delete") + + +def dns_add(zone, record_name, ttl, record_type, rdata): + """ + Issues a DNS update to the backend server + :param zone: a populated zone model + :param record_name: the name of the record to add + :param ttl: the ttl value of the record + :param record_type: the type of record being added + :param rdata: the rdata string + :return: + """ + return dns_do_command(zone, record_name, record_type, "add", ttl, rdata) + + +def dns_resolve(zone, record_name, record_type): + """ + Performs a dns query to find the record name and type against the zone + :param zone: a populated zone model + :param record_name: the name of the record to lookup + :param record_type: the type of record to lookup + :return: An array of dictionaries, each dict containing fields rdata, type, name, ttl, dclass + """ + vinyldns_resolver = dns.resolver.Resolver(configure=False) + + name_server, name_server_port = dns_server_port(zone) + + vinyldns_resolver.nameservers = [name_server] + vinyldns_resolver.port = name_server_port + vinyldns_resolver.domain = zone['name'] + + fqdn = record_name + '.' + vinyldns_resolver.domain + if record_name == vinyldns_resolver.domain: + # assert that we are looking up the zone name / @ symbol + fqdn = vinyldns_resolver.domain + + print "looking up " + fqdn + + try: + answers = vinyldns_resolver.query(fqdn, record_type) + except NXDOMAIN: + print "query returned NXDOMAIN" + answers = [] + except dns.resolver.NoAnswer: + print "query returned NoAnswer" + answers = [] + + if answers: + # dns python is goofy, looks like we have to parse text + # each record in the rrset is delimited by a \n + records = str(answers.rrset).split('\n') + + # for each record, we have exactly 4 fields in order: 1 record name; 2 TTL; 3 DCLASS; 4 TYPE; 5 RDATA + # construct a simple dictionary based on that split + return map(lambda x: parse_record(x), records) + else: + return [] + + +def parse_record(record_string): + # for each record, we have exactly 4 fields in order: 1 record name; 2 TTL; 3 DCLASS; 4 TYPE; 5 RDATA + parts = record_string.split(' ') + + print "record parts" + print str(parts) + + # any parts over 4 have to be kept together + offset = record_string.find(parts[3]) + len(parts[3]) + 1 + length = len(record_string) - offset + record_data = record_string[offset:offset + length] + + record = { + 'name': parts[0], + 'ttl': int(str(parts[1])), + 'dclass': parts[2], + 'type': parts[3], + 'rdata': record_data + } + + print "parsed record:" + print str(record) + return record + + +def generate_acl_rule(access_level, **kw): + acl_rule = { + 'accessLevel': access_level, + 'description': 'some_test_rule' + } + if ('userId' in kw): + acl_rule['userId'] = kw['userId'] + if ('groupId' in kw): + acl_rule['groupId'] = kw['groupId'] + if ('recordTypes' in kw): + acl_rule['recordTypes'] = kw['recordTypes'] + if ('recordMask' in kw): + acl_rule['recordMask'] = kw['recordMask'] + + return acl_rule + + +def add_rules_to_zone(zone, new_rules): + import copy + + updated_zone = copy.deepcopy(zone) + updated_rules = updated_zone['acl']['rules'] + rules_to_add = filter(lambda x: x not in updated_rules, new_rules) + updated_rules.extend(rules_to_add) + updated_zone['acl']['rules'] = updated_rules + return updated_zone + +def remove_rules_from_zone(zone, deleted_rules): + import copy + + updated_zone = copy.deepcopy(zone) + existing_rules = updated_zone['acl']['rules'] + trimmed_rules = filter(lambda x: x in existing_rules, deleted_rules) + updated_zone['acl']['rules'] = trimmed_rules + + return updated_zone + +def add_ok_acl_rules(test_context, rules): + updated_zone = add_rules_to_zone(test_context.ok_zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def add_ip4_acl_rules(test_context, rules): + updated_zone = add_rules_to_zone(test_context.ip4_reverse_zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def add_ip6_acl_rules(test_context, rules): + updated_zone = add_rules_to_zone(test_context.ip6_reverse_zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def add_classless_acl_rules(test_context, rules): + updated_zone = add_rules_to_zone(test_context.classless_zone_delegation_zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def remove_ok_acl_rules(test_context, rules): + zone = test_context.ok_vinyldns_client.get_zone(test_context.ok_zone['id'])['zone'] + updated_zone = remove_rules_from_zone(zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def remove_ip4_acl_rules(test_context, rules): + zone = test_context.ok_vinyldns_client.get_zone(test_context.ip4_reverse_zone['id'])['zone'] + updated_zone = remove_rules_from_zone(zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def remove_ip6_acl_rules(test_context, rules): + zone = test_context.ok_vinyldns_client.get_zone(test_context.ip6_reverse_zone['id'])['zone'] + updated_zone = remove_rules_from_zone(zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def remove_classless_acl_rules(test_context, rules): + zone = test_context.ok_vinyldns_client.get_zone(test_context.classless_zone_delegation_zone['id'])['zone'] + updated_zone = remove_rules_from_zone(zone, rules) + update_change = test_context.ok_vinyldns_client.update_zone(updated_zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def clear_ok_acl_rules(test_context): + zone = test_context.ok_zone + zone['acl']['rules'] = [] + update_change = test_context.ok_vinyldns_client.update_zone(zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def clear_ip4_acl_rules(test_context): + zone = test_context.ip4_reverse_zone + zone['acl']['rules'] = [] + update_change = test_context.ok_vinyldns_client.update_zone(zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def clear_ip6_acl_rules(test_context): + zone = test_context.ip6_reverse_zone + zone['acl']['rules'] = [] + update_change = test_context.ok_vinyldns_client.update_zone(zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def clear_classless_acl_rules(test_context): + zone = test_context.classless_zone_delegation_zone + zone['acl']['rules'] = [] + update_change = test_context.ok_vinyldns_client.update_zone(zone, status=202) + test_context.ok_vinyldns_client.wait_until_zone_change_status(update_change, 'Synced') + +def seed_text_recordset(client, record_name, zone, records=[{'text':'someText'}]): + new_rs = { + 'zoneId': zone['id'], + 'name': record_name, + 'type': 'TXT', + 'ttl': 100, + 'records': records + } + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + if client.wait_until_recordset_exists(result_rs['zoneId'], result_rs['id']): + print "\r\n!!! record set exists !!!" + else: + print "\r\n!!! record set does not exist !!!" + + return result_rs + +def seed_ptr_recordset(client, record_name, zone, records=[{'ptrdname':'foo.com.'}]): + new_rs = { + 'zoneId': zone['id'], + 'name': record_name, + 'type': 'PTR', + 'ttl': 100, + 'records': records + } + result = client.create_recordset(new_rs, status=202) + result_rs = result['recordSet'] + if client.wait_until_recordset_exists(result_rs['zoneId'], result_rs['id']): + print "\r\n!!! record set exists !!!" + else: + print "\r\n!!! record set does not exist !!!" + + return result_rs + + +def clear_zones(client): + # Get the groups for the ok user + groups = client.list_all_my_groups() + group_ids = map(lambda x: x['id'], groups) + + zones = client.list_zones()['zones'] + + import json + for zone in zones: + print "list zones found..." + print json.dumps(zone, indent=3) + + # we only want to delete zones that the ok user "owns" + zones_to_delete = filter(lambda x: (x['adminGroupId'] in group_ids) or (x['account'] in group_ids), zones) + zone_names_to_delete = map(lambda x: x['name'], zones_to_delete) + + print "zones to delete:" + for name in zone_names_to_delete: + print name + + zoneids_to_delete = map(lambda x: x['id'], zones_to_delete) + + client.abandon_zones(zoneids_to_delete) + + +def clear_groups(client, exclude=[]): + groups = client.list_all_my_groups() + group_ids = map(lambda x: x['id'], groups) + + for group_id in group_ids: + if not group_id in exclude: + client.delete_group(group_id, status=200) + +def get_change_A_AAAA_json(input_name, record_type="A", ttl=200, address="1.1.1.1", change_type="Add"): + if change_type == "Add": + json = { + "changeType": change_type, + "inputName": input_name, + "type": record_type, + "ttl": ttl, + "record": { + "address": address + } + } + else: + json = { + "changeType": "DeleteRecordSet", + "inputName": input_name, + "type": record_type + } + return json + +def get_change_CNAME_json(input_name, ttl=200, cname="test.com", change_type="Add"): + if change_type == "Add": + json = { + "changeType": change_type, + "inputName": input_name, + "type": "CNAME", + "ttl": ttl, + "record": { + "cname": cname + } + } + else: + json = { + "changeType": "DeleteRecordSet", + "inputName": input_name, + "type": "CNAME" + } + return json + +def get_change_PTR_json(ip, ttl=200, ptrdname="test.com", change_type="Add"): + if change_type == "Add": + json = { + "changeType": change_type, + "inputName": ip, + "type": "PTR", + "ttl": ttl, + "record": { + "ptrdname": ptrdname + } + } + else: + json = { + "changeType": "DeleteRecordSet", + "inputName": ip, + "type": "PTR" + } + return json + + +def get_change_TXT_json(input_name, record_type="TXT", ttl=200, text="test", change_type="Add"): + if change_type == "Add": + json = { + "changeType": change_type, + "inputName": input_name, + "type": record_type, + "ttl": ttl, + "record": { + "text": text + } + } + else: + json = { + "changeType": "DeleteRecordSet", + "inputName": input_name, + "type": record_type + } + return json + + +def get_change_MX_json(input_name, ttl=200, preference=1, exchange="foo.bar.", change_type="Add"): + if change_type == "Add": + json = { + "changeType": change_type, + "inputName": input_name, + "type": "MX", + "ttl": ttl, + "record": { + "preference": preference, + "exchange": exchange + } + } + else: + json = { + "changeType": "DeleteRecordSet", + "inputName": input_name, + "type": "MX" + } + return json + +def get_recordset_json(zone, rname, type, rdata_list, ttl=200): + json = { + "zoneId": zone['id'], + "name": rname, + "type": type, + "ttl": ttl, + "records": rdata_list + } + return json + +def clear_recordset_list(to_delete, client): + delete_changes = [] + for result_rs in to_delete: + try: + delete_result = client.delete_recordset(result_rs['zone']['id'], result_rs['recordSet']['id'], status=202) + delete_changes.append(delete_result) + except: + pass + for change in delete_changes: + try: + client.wait_until_recordset_change_status(change, 'Complete') + except: + pass + +def clear_zoneid_rsid_tuple_list(to_delete, client): + delete_changes = [] + for tup in to_delete: + try: + delete_result = client.delete_recordset(tup[0], tup[1], status=202) + delete_changes.append(delete_result) + except: + pass + for change in delete_changes: + try: + client.wait_until_recordset_change_status(change, 'Complete') + except: + pass diff --git a/modules/api/functional_test/vinyldns_context.py b/modules/api/functional_test/vinyldns_context.py new file mode 100644 index 0000000000..a831277ebb --- /dev/null +++ b/modules/api/functional_test/vinyldns_context.py @@ -0,0 +1,16 @@ +class VinylDNSTestContext: + dns_ip = 'localhost' + dns_zone_name = 'vinyldns.' + dns_rev_v4_zone_name = '30.172.in-addr.arpa.' + dns_rev_v6_zone_name = '1.9.e.f.c.c.7.2.9.6.d.f.ip6.arpa.' + dns_key_name = 'vinyldns.' + dns_key = 'nzisn+4G2ldMn0q1CV3vsg==' + vinyldns_url = 'http://localhost:9000' + + @staticmethod + def configure(ip, zone, key_name, key, url): + VinylDNSTestContext.dns_ip = ip + VinylDNSTestContext.dns_zone_name = zone + VinylDNSTestContext.dns_key_name = key_name + VinylDNSTestContext.dns_key = key + VinylDNSTestContext.vinyldns_url = url diff --git a/modules/api/functional_test/vinyldns_python.py b/modules/api/functional_test/vinyldns_python.py new file mode 100644 index 0000000000..f546a2a8fe --- /dev/null +++ b/modules/api/functional_test/vinyldns_python.py @@ -0,0 +1,857 @@ +import json +import time +import logging +import collections + +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry +from hamcrest import * + +# TODO: Didn't like this boto request signer, fix when moving back +from boto_request_signer import BotoRequestSigner + +# Python 2/3 compatibility +from requests.compat import urljoin, urlparse, urlsplit +from builtins import str +from future.utils import iteritems +from future.moves.urllib.parse import parse_qs + +try: + basestring +except NameError: + basestring = str + +logger = logging.getLogger(__name__) + +__all__ = [u'VinylDNSClient', u'MAX_RETRIES', u'RETRY_WAIT'] + +MAX_RETRIES = 30 +RETRY_WAIT = 0.05 + +class VinylDNSClient(object): + + def __init__(self, url, access_key, secret_key): + self.index_url = url + self.headers = { + u'Accept': u'application/json, text/plain', + u'Content-Type': u'application/json' + } + + self.signer = BotoRequestSigner(self.index_url, + access_key, secret_key) + + self.session = self.requests_retry_session() + self.session_not_found_ok = self.requests_retry_not_found_ok_session() + + def requests_retry_not_found_ok_session(self, + retries=5, + backoff_factor=0.4, + status_forcelist=(500, 502, 504), + session=None, + ): + session = session or requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount(u'http://', adapter) + session.mount(u'https://', adapter) + return session + + def requests_retry_session(self, + retries=5, + backoff_factor=0.4, + status_forcelist=(500, 502, 504), + session=None, + ): + session = session or requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount(u'http://', adapter) + session.mount(u'https://', adapter) + return session + + def make_request(self, url, method=u'GET', headers=None, body_string=None, sign_request=True, not_found_ok=False, **kwargs): + + # pull out status or None + status_code = kwargs.pop(u'status', None) + + # remove retries arg if provided + kwargs.pop(u'retries', None) + + path = urlparse(url).path + + # we must parse the query string so we can provide it if it exists so that we can pass it to the + # build_vinyldns_request so that it can be properly included in the AWS signing... + query = parse_qs(urlsplit(url).query) + + if query: + # the problem with parse_qs is that it will return a list for ALL params, even if they are a single value + # we need to essentially flatten the params if a param has only one value + query = dict((k, v if len(v)>1 else v[0]) + for k, v in iteritems(query)) + + if sign_request: + signed_headers, signed_body = self.build_vinyldns_request(method, path, body_string, query, + with_headers=headers or {}, **kwargs) + else: + signed_headers = headers or {} + signed_body = body_string + + if not_found_ok: + response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers, **kwargs) + else: + response = self.session.request(method, url, data=signed_body, headers=signed_headers, **kwargs) + + if status_code is not None: + if isinstance(status_code, collections.Iterable): + assert_that(response.status_code, is_in(status_code)) + else: + assert_that(response.status_code, is_(status_code)) + + try: + return response.status_code, response.json() + except: + return response.status_code, response.text + + def ping(self): + """ + Simple ping request + :return: the content of the response, which should be PONG + """ + url = urljoin(self.index_url, '/ping') + + response, data = self.make_request(url) + return data + + def get_status(self): + """ + Gets processing status + :return: the content of the response + """ + url = urljoin(self.index_url, '/status') + + response, data = self.make_request(url) + + return data + + def post_status(self, status): + """ + Update processing status + :return: the content of the response + """ + url = urljoin(self.index_url, '/status?processingDisabled={}'.format(status)) + response, data = self.make_request(url, 'POST', self.headers) + + return data + + def color(self): + """ + Gets the current color for the application + :return: the content of the response, which should be "blue" or "green" + """ + url = urljoin(self.index_url, '/color') + response, data = self.make_request(url) + return data + + def health(self): + """ + Checks the health of the app, asserts that a 200 should be returned, otherwise + this will fail + """ + url = urljoin(self.index_url, '/health') + self.make_request(url, sign_request=False) + + def create_group(self, group, **kwargs): + """ + Creates a new group + :param group: A group dictionary that can be serialized to json + :return: the content of the response, which should be a group json + """ + + url = urljoin(self.index_url, u'/groups') + response, data = self.make_request(url, u'POST', self.headers, json.dumps(group), **kwargs) + + return data + + def get_group(self, group_id, **kwargs): + """ + Gets a group + :param group_id: Id of the group to get + :return: the group json + """ + + url = urljoin(self.index_url, u'/groups/' + group_id) + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + + return data + + def delete_group(self, group_id, **kwargs): + """ + Deletes a group + :param group_id: Id of the group to delete + :return: the group json + """ + + url = urljoin(self.index_url, u'/groups/' + group_id) + response, data = self.make_request(url, u'DELETE', self.headers, not_found_ok=True, **kwargs) + + return data + + def update_group(self, group_id, group, **kwargs): + """ + Update an existing group + :param group_id: The id of the group being updated + :param group: A group dictionary that can be serialized to json + :return: the content of the response, which should be a group json + """ + + url = urljoin(self.index_url, u'/groups/{0}'.format(group_id)) + response, data = self.make_request(url, u'PUT', self.headers, json.dumps(group), not_found_ok=True, **kwargs) + + return data + + def list_my_groups(self, group_name_filter=None, start_from=None, max_items=None, **kwargs): + """ + Retrieves my groups + :param start_from: the start key of the page + :param max_items: the page limit + :param group_name_filter: only returns groups whose names contain filter string + :return: the content of the response + """ + + args = [] + if group_name_filter: + args.append(u'groupNameFilter={0}'.format(group_name_filter)) + if start_from: + args.append(u'startFrom={0}'.format(start_from)) + if max_items is not None: + args.append(u'maxItems={0}'.format(max_items)) + + url = urljoin(self.index_url, u'/groups') + u'?' + u'&'.join(args) + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + + return data + + def list_all_my_groups(self, group_name_filter=None, **kwargs): + """ + Retrieves all my groups + :param group_name_filter: only returns groups whose names contain filter string + :return: the content of the response + """ + + groups = [] + args = [] + if group_name_filter: + args.append(u'groupNameFilter={0}'.format(group_name_filter)) + + url = urljoin(self.index_url, u'/groups') + u'?' + u'&'.join(args) + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + + groups.extend(data[u'groups']) + + while u'nextId' in data: + args = [] + + if group_name_filter: + args.append(u'groupNameFilter={0}'.format(group_name_filter)) + if u'nextId' in data: + args.append(u'startFrom={0}'.format(data[u'nextId'])) + + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + groups.extend(data[u'groups']) + + return groups + + def list_members_group(self, group_id, start_from=None, max_items=None, **kwargs): + """ + List the members of an existing group + :param group_id: the Id of an existing group + :param start_from: the Id a member of the group + :param max_items: the max number of items to be returned + :return: the json of the members + """ + if start_from is None and max_items is None: + url = urljoin(self.index_url, u'/groups/{0}/members'.format(group_id)) + elif start_from is None and max_items is not None: + url = urljoin(self.index_url, u'/groups/{0}/members?maxItems={1}'.format(group_id, max_items)) + elif start_from is not None and max_items is None: + url = urljoin(self.index_url, u'/groups/{0}/members?startFrom={1}'.format(group_id, start_from)) + elif start_from is not None and max_items is not None: + url = urljoin(self.index_url, u'/groups/{0}/members?startFrom={1}&maxItems={2}'.format(group_id, + start_from, + max_items)) + + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, **kwargs) + + return data + + def list_group_admins(self, group_id, **kwargs): + """ + returns the group admins + :param group_id: the Id of the group + :return: the user info of the admins + """ + url = urljoin(self.index_url, u'/groups/{0}/admins'.format(group_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, **kwargs) + + return data + + def get_group_changes(self, group_id, start_from=None, max_items=None, **kwargs): + """ + List the changes of an existing group + :param group_id: the Id of an existing group + :param start_from: the Id a group change + :param max_items: the max number of items to be returned + :return: the json of the members + """ + if start_from is None and max_items is None: + url = urljoin(self.index_url, u'/groups/{0}/activity'.format(group_id)) + elif start_from is None and max_items is not None: + url = urljoin(self.index_url, u'/groups/{0}/activity?maxItems={1}'.format(group_id, max_items)) + elif start_from is not None and max_items is None: + url = urljoin(self.index_url, u'/groups/{0}/activity?startFrom={1}'.format(group_id, start_from)) + elif start_from is not None and max_items is not None: + url = urljoin(self.index_url, u'/groups/{0}/activity?startFrom={1}&maxItems={2}'.format(group_id, + start_from, + max_items)) + + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + + return data + + def create_zone(self, zone, **kwargs): + """ + Creates a new zone with the given name and email + :param zone: the zone to be created + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones') + response, data = self.make_request(url, u'POST', self.headers, json.dumps(zone), **kwargs) + return data + + def update_zone(self, zone, **kwargs): + """ + Updates a zone + :param zone: the zone to be created + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/{0}'.format(zone[u'id'])) + response, data = self.make_request(url, u'PUT', self.headers, json.dumps(zone), not_found_ok=True, **kwargs) + return data + + def sync_zone(self, zone_id, **kwargs): + """ + Syncs a zone + :param zone: the zone to be updated + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/{0}/sync'.format(zone_id)) + response, data = self.make_request(url, u'POST', self.headers, not_found_ok=True, **kwargs) + + return data + + def delete_zone(self, zone_id, **kwargs): + """ + Deletes the zone for the given id + :param zone_id: the id of the zone to be deleted + :return: nothing, will fail if the status code was not expected + """ + url = urljoin(self.index_url, u'/zones/{0}'.format(zone_id)) + response, data = self.make_request(url, u'DELETE', self.headers, not_found_ok=True, **kwargs) + + return data + + def get_zone(self, zone_id, **kwargs): + """ + Gets a zone for the given zone id + :param zone_id: the id of the zone to retrieve + :return: the zone, or will 404 if not found + """ + url = urljoin(self.index_url, u'/zones/{0}'.format(zone_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, **kwargs) + + return data + + def get_zone_history(self, zone_id, **kwargs): + """ + Gets the zone history for the given zone id + :param zone_id: the id of the zone to retrieve + :return: the zone, or will 404 if not found + """ + url = urljoin(self.index_url, u'/zones/{0}/history'.format(zone_id)) + + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, **kwargs) + return data + + def get_zone_change(self, zone_change, **kwargs): + """ + Gets a zone change with the provided id + + Unfortunately, there is no endpoint, so we have to get all zone history and parse + """ + zone_change_id = zone_change[u'id'] + change = None + + def change_id_match(possible_match): + return possible_match[u'id'] == zone_change_id + + history = self.get_zone_history(zone_change[u'zone'][u'id']) + if u'zoneChanges' in history: + zone_changes = history[u'zoneChanges'] + matching_changes = filter(change_id_match, zone_changes) + + if len(matching_changes) > 0: + change = matching_changes[0] + + return change + + def list_zone_changes(self, zone_id, start_from=None, max_items=None, **kwargs): + """ + Gets the zone changes for the given zone id + :param zone_id: the id of the zone to retrieve + :param start_from: the start key of the page + :param max_items: the page limit + :return: the zone, or will 404 if not found + """ + args = [] + if start_from: + args.append(u'startFrom={0}'.format(start_from)) + if max_items is not None: + args.append(u'maxItems={0}'.format(max_items)) + url = urljoin(self.index_url, u'/zones/{0}/changes'.format(zone_id)) + u'?' + u'&'.join(args) + + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, **kwargs) + return data + + def list_recordset_changes(self, zone_id, start_from=None, max_items=None, **kwargs): + """ + Gets the recordset changes for the given zone id + :param zone_id: the id of the zone to retrieve + :param start_from: the start key of the page + :param max_items: the page limit + :return: the zone, or will 404 if not found + """ + args = [] + if start_from: + args.append(u'startFrom={0}'.format(start_from)) + if max_items is not None: + args.append(u'maxItems={0}'.format(max_items)) + url = urljoin(self.index_url, u'/zones/{0}/recordsetchanges'.format(zone_id)) + u'?' + u'&'.join(args) + + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, **kwargs) + return data + + def list_zones(self, name_filter=None, start_from=None, max_items=None, **kwargs): + """ + Gets a list of zones that currently exist + :return: a list of zones + """ + url = urljoin(self.index_url, u'/zones') + + query = [] + if name_filter: + query.append(u'nameFilter=' + name_filter) + + if start_from: + query.append(u'startFrom=' + str(start_from)) + + if max_items: + query.append(u'maxItems=' + str(max_items)) + + if query: + url = url + u'?' + u'&'.join(query) + + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + return data + + def create_recordset(self, recordset, **kwargs): + """ + Creates a new recordset + :param recordset: the recordset to be created + :return: the content of the response + """ + if recordset and u'name' in recordset: + recordset[u'name'] = recordset[u'name'].replace(u'_', u'-') + + url = urljoin(self.index_url, u'/zones/{0}/recordsets'.format(recordset[u'zoneId'])) + response, data = self.make_request(url, u'POST', self.headers, json.dumps(recordset), **kwargs) + return data + + def delete_recordset(self, zone_id, rs_id, **kwargs): + """ + Deletes an existing recordset + :param zone_id: the zone id the recordset belongs to + :param rs_id: the id of the recordset to be deleted + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}'.format(zone_id, rs_id)) + + response, data = self.make_request(url, u'DELETE', self.headers, not_found_ok=True, **kwargs) + return data + + def update_recordset(self, recordset, **kwargs): + """ + Deletes an existing recordset + :param recordset: the recordset to be updated + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}'.format(recordset[u'zoneId'], recordset[u'id'])) + + response, data = self.make_request(url, u'PUT', self.headers, json.dumps(recordset), not_found_ok=True, **kwargs) + return data + + def get_recordset(self, zone_id, rs_id, **kwargs): + """ + Gets an existing recordset + :param zone_id: the zone id the recordset belongs to + :param rs_id: the id of the recordset to be retrieved + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}'.format(zone_id, rs_id)) + + response, data = self.make_request(url, u'GET', self.headers, None, not_found_ok=True, **kwargs) + return data + + def get_recordset_change(self, zone_id, rs_id, change_id, **kwargs): + """ + Gets an existing recordset change + :param zone_id: the zone id the recordset belongs to + :param rs_id: the id of the recordset to be retrieved + :param change_id: the id of the change to be retrieved + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}/changes/{2}'.format(zone_id, rs_id, change_id)) + + response, data = self.make_request(url, u'GET', self.headers, None, not_found_ok=True, **kwargs) + return data + + def list_recordsets(self, zone_id, start_from=None, max_items=None, record_name_filter=None, **kwargs): + """ + Retrieves all recordsets in a zone + :param zone_id: the zone to retrieve + :param start_from: the start key of the page + :param max_items: the page limit + :param record_name_filter: only returns recordsets whose names contain filter string + :return: the content of the response + """ + args = [] + if start_from: + args.append(u'startFrom={0}'.format(start_from)) + if max_items is not None: + args.append(u'maxItems={0}'.format(max_items)) + if record_name_filter: + args.append(u'recordNameFilter={0}'.format(record_name_filter)) + + url = urljoin(self.index_url, u'/zones/{0}/recordsets'.format(zone_id)) + u'?' + u'&'.join(args) + + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + return data + + def create_batch_change(self, batch_change_input, **kwargs): + """ + Creates a new batch change + :param batch_change_input: the batchchange to be created + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/batchrecordchanges') + response, data = self.make_request(url, u'POST', self.headers, json.dumps(batch_change_input), **kwargs) + return data + + def get_batch_change(self, batch_change_id, **kwargs): + """ + Gets an existing batch change + :param batch_change_id: the unique identifier of the batchchange + :return: the content of the response + """ + url = urljoin(self.index_url, u'/zones/batchrecordchanges/{0}'.format(batch_change_id)) + response, data = self.make_request(url, u'GET', self.headers, None, not_found_ok=True, **kwargs) + return data + + def list_batch_change_summaries(self, start_from=None, max_items=None, **kwargs): + """ + Gets list of user's batch change summaries + :return: the content of the response + """ + args = [] + if start_from: + args.append(u'startFrom={0}'.format(start_from)) + if max_items is not None: + args.append(u'maxItems={0}'.format(max_items)) + + url = urljoin(self.index_url, u'/zones/batchrecordchanges') + u'?' + u'&'.join(args) + + response, data = self.make_request(url, u'GET', self.headers, **kwargs) + return data + + def build_vinyldns_request(self, method, path, body_data, params=None, **kwargs): + + if isinstance(body_data, basestring): + body_string = body_data + else: + body_string = json.dumps(body_data) + + new_headers = {u'X-Amz-Target': u'VinylDNS'} + new_headers.update(kwargs.get(u'with_headers', dict())) + + suppress_headers = kwargs.get(u'suppress_headers', list()) + + headers = self.build_headers(new_headers, suppress_headers) + + auth_header = self.signer.build_auth_header(method, path, headers, body_string, params) + headers[u'Authorization'] = auth_header + + return headers, body_string + + @staticmethod + def build_headers(new_headers, suppressed_keys): + """Construct HTTP headers for a request.""" + + def canonical_header_name(field_name): + return u'-'.join(word.capitalize() for word in field_name.split(u'-')) + + import datetime + now = datetime.datetime.utcnow() + headers = {u'Content-Type': u'application/x-amz-json-1.0', + u'Date': now.strftime(u'%a, %d %b %Y %H:%M:%S GMT'), + u'X-Amz-Date': now.strftime(u'%Y%m%dT%H%M%SZ')} + + for k, v in iteritems(new_headers): + headers[canonical_header_name(k)] = v + + for k in map(canonical_header_name, suppressed_keys): + if k in headers: + del headers[k] + + return headers + + def add_zone_acl_rule_with_wait(self, zone_id, acl_rule, sign_request=True, **kwargs): + """ + Puts an acl rule on the zone and waits for success + :param zone_id: The id of the zone to attach the acl rule to + :param acl_rule: The acl rule contents + :param sign_request: An indicator if we should sign the request; useful for testing auth + :return: the content of the response + """ + rule = self.add_zone_acl_rule(zone_id, acl_rule, sign_request, **kwargs) + self.wait_until_zone_change_status(rule, 'Synced') + + return rule + + def add_zone_acl_rule(self, zone_id, acl_rule, sign_request=True, **kwargs): + """ + Puts an acl rule on the zone + :param zone_id: The id of the zone to attach the acl rule to + :param acl_rule: The acl rule contents + :param sign_request: An indicator if we should sign the request; useful for testing auth + :return: the content of the response + """ + url = urljoin(self.index_url, '/zones/{0}/acl/rules'.format(zone_id)) + response, data = self.make_request(url, 'PUT', self.headers, json.dumps(acl_rule), sign_request=sign_request, **kwargs) + + return data + + def delete_zone_acl_rule_with_wait(self, zone_id, acl_rule, sign_request=True, **kwargs): + """ + Deletes an acl rule from the zone and waits for success + :param zone_id: The id of the zone to remove the acl from + :param acl_rule: The acl rule to remove + :param sign_request: An indicator if we should sign the request; useful for testing auth + :return: the content of the response + """ + rule = self.delete_zone_acl_rule(zone_id, acl_rule, sign_request, **kwargs) + self.wait_until_zone_change_status(rule, 'Synced') + + return rule + + def delete_zone_acl_rule(self, zone_id, acl_rule, sign_request=True, **kwargs): + """ + Deletes an acl rule from the zone + :param zone_id: The id of the zone to remove the acl from + :param acl_rule: The acl rule to remove + :param sign_request: An indicator if we should sign the request; useful for testing auth + :return: the content of the response + """ + url = urljoin(self.index_url, '/zones/{0}/acl/rules'.format(zone_id)) + response, data = self.make_request(url, 'DELETE', self.headers, json.dumps(acl_rule), sign_request=sign_request, **kwargs) + + return data + + def wait_until_recordset_deleted(self, zone_id, record_set_id, **kwargs): + retries = MAX_RETRIES + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}'.format(zone_id, record_set_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + while response != 404 and retries > 0: + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}'.format(zone_id, record_set_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + retries -= 1 + time.sleep(RETRY_WAIT) + + return response == 404 + + def wait_until_zone_change_status(self, zone_change, expected_status): + """ + Waits until the zone change status matches the expected status + """ + zone_change_id = zone_change[u'id'] + + def change_id_match(change): + return change[u'id'] == zone_change_id + + change = zone_change + retries = MAX_RETRIES + while change[u'status'] != expected_status and retries > 0: + history = self.get_zone_history(zone_change[u'zone'][u'id']) + + if u'zoneChanges' in history: + zone_changes = history[u'zoneChanges'] + matching_changes = filter(change_id_match, zone_changes) + + if len(matching_changes) > 0: + change = matching_changes[0] + time.sleep(RETRY_WAIT) + retries -= 1 + + return change[u'status'] == expected_status + + def wait_until_zone_deleted(self, zone_id, **kwargs): + """ + Waits a period of time for the zone deletion to complete. + + :param zone_id: the id of the zone that has been deleted. + :param kw: Additional parameters for the http request + :return: True when the zone deletion is complete False if the timeout expires + """ + retries = MAX_RETRIES + url = urljoin(self.index_url, u'/zones/{0}'.format(zone_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + while response != 404 and retries > 0: + url = urljoin(self.index_url, u'/zones/{0}'.format(zone_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + retries -= 1 + time.sleep(RETRY_WAIT) + + return response == 404 + + def wait_until_zone_exists(self, zone_change, **kwargs): + """ + Waits a period of time for the zone creation to complete. + + :param zone_change: the create zone change for the zone that has been created. + :param kw: Additional parameters for the http request + :return: True when the zone creation is complete False if the timeout expires + """ + zone_id = zone_change[u'zone'][u'id'] + retries = MAX_RETRIES + url = urljoin(self.index_url, u'/zones/{0}'.format(zone_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + while response != 200 and retries > 0: + url = urljoin(self.index_url, u'/zones/{0}'.format(zone_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + retries -= 1 + time.sleep(RETRY_WAIT) + + return response == 200 + + def wait_until_recordset_exists(self, zone_id, record_set_id, **kwargs): + """ + Waits a period of time for the record set creation to complete. + + :param zone_id: the id of the zone the record set lives in + :param record_set_id: the id of the recprdset that has been created. + :param kw: Additional parameters for the http request + :return: True when the recordset creation is complete False if the timeout expires + """ + retries = MAX_RETRIES + url = urljoin(self.index_url, u'/zones/{0}/recordsets/{1}'.format(zone_id, record_set_id)) + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + while response != 200 and retries > 0: + response, data = self.make_request(url, u'GET', self.headers, not_found_ok=True, status=(200, 404), **kwargs) + retries -= 1 + time.sleep(RETRY_WAIT) + + if response == 200: + return data + + return response == 200 + + def abandon_zones(self, zone_ids, **kwargs): + #delete each zone + for zone_id in zone_ids: + self.delete_zone(zone_id, status=(202, 404)) + + # Wait until each zone is gone + for zone_id in zone_ids: + success = self.wait_until_zone_deleted(zone_id) + assert_that(success, is_(True)) + + def wait_until_recordset_change_status(self, rs_change, expected_status): + """ + Waits a period of time for a recordset to be active by repeatedly fetching the recordset and testing + the recordset status + :param rs_change: The recordset change being evaluated, must include the id and the zone id + :return: The recordset change that is active, or it could still be pending if the number of retries was exhausted + """ + change = rs_change + retries = MAX_RETRIES + while change['status'] != expected_status and retries > 0: + latest_change = self.get_recordset_change(change['recordSet']['zoneId'], change['recordSet']['id'], + change['id'], status=(200,404)) + print "\r\n --- latest change is " + str(latest_change) + if "Unable to find record set change" in latest_change: + change = change + else: + change = latest_change + + time.sleep(RETRY_WAIT) + retries -= 1 + + if change['status'] != expected_status: + print 'Failed waiting for record change status' + if 'systemMessage' in change: + print 'systemMessage is ' + change['systemMessage'] + + assert_that(change['status'], is_(expected_status)) + return change + + def batch_is_completed(self, batch_change): + return batch_change['status'] in ['Complete', 'Failed', 'PartialFailure'] + + def wait_until_batch_change_completed(self, batch_change): + """ + Waits a period of time for a batch change to be complete (or failed) by repeatedly fetching the change and testing + the status + :param batch_change: The batch change being evaluated + :return: The batch change that is active, or it could still be pending if the number of retries was exhausted + """ + change = batch_change + retries = MAX_RETRIES + + while not self.batch_is_completed(change) and retries > 0: + latest_change = self.get_batch_change(change['id'], status=(200,404)) + print "\r\n --- latest change is " + str(latest_change) + if "cannot be found" in latest_change: + change = change + else: + change = latest_change + + time.sleep(RETRY_WAIT) + retries -= 1 + + if not self.batch_is_completed(change): + print 'Failed waiting for record change status' + print change + + assert_that(self.batch_is_completed(change), is_(True)) + return change diff --git a/modules/api/functional_test/zone_inject.py b/modules/api/functional_test/zone_inject.py new file mode 100644 index 0000000000..111058e173 --- /dev/null +++ b/modules/api/functional_test/zone_inject.py @@ -0,0 +1,48 @@ +import requests +import json + +newzone = "http://localhost:9000/zones" + + +names = ["cap", "video", "aae", "papi", "dns-ops", "ios", "home", "android", "games", "viper", "headwaters", "xtv", "consec", "media", "accounts"]; + +records = ["10.25.3.2","155.65.10.3", "10.1.1.1", "168.82.76.5", "192.168.99.88", "FE80:0000:0000:0000:0202:B3FF:FE1E:8329", "GF77:0000:0000:0000:0411:B3DF:FE2E:4444", "CC42:0000:0000:0000:0509:B3FF:FE3E:6543", "BG50:0000:0000:0000:0203:C2EE:G3F4:9823","AA90:0000:0000:0000:0608:C2EE:FE4E:1234", "staging", "test", "admin", "assets", "admin"]; + +for x in range(0, 15): + zonename = names[x] + zoneemail = 'testuser'+ str(x) +'@example.com' + payload = {"name": zonename, "origin": "vinyldns", "email": zoneemail} + headers = {'Content-type': 'application/json'} + r = requests.post(newzone, data=json.dumps(payload),headers=headers) + print(r.text) + + +zones = requests.get(newzone) +zone_data = zones.json() + +z=0 +for i in zone_data['zones']: + if z<5: + z=z+1 + recurl = newzone + '/' + str(i['id']) + '/recordsets' + print recurl + payload = {"zoneId":i['id'],"name":"record."+i['name'],"type":"A","ttl":300,"records":[{"address":records[z-1]}]} + headers = {'Content-type': 'application/json'} + r = requests.post(recurl, data=json.dumps(payload),headers=headers) + print(r.text) + elif 4 + + + %msg%n + + + + target/test/test.log + true + + %-4relative [%thread] %-5level %logger{35} - %msg%n + + + + + + + diff --git a/modules/api/src/it/scala/vinyldns/api/domain/dns/DnsConversionsIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/domain/dns/DnsConversionsIntegrationSpec.scala new file mode 100644 index 0000000000..a3cdc03fa7 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/domain/dns/DnsConversionsIntegrationSpec.scala @@ -0,0 +1,63 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain.dns + +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import org.xbill.DNS +import vinyldns.api.{ResultHelpers, VinylDNSTestData} +import vinyldns.api.domain.dns.DnsProtocol.{DnsResponse, NoError} +import vinyldns.api.domain.record.RecordSetChange +import vinyldns.api.domain.zone.{Zone, ZoneConnection, ZoneStatus} + +class DnsConversionsIntegrationSpec + extends WordSpec + with Matchers + with BeforeAndAfterAll + with VinylDNSTestData + with ResultHelpers { + + private val zoneName = "vinyldns." + private var testZone: Zone = _ + + override protected def beforeAll(): Unit = + testZone = Zone( + zoneName, + "test@test.com", + ZoneStatus.Active, + connection = Some( + ZoneConnection("vinyldns.", "vinyldns.", "nzisn+4G2ldMn0q1CV3vsg==", "127.0.0.1:19001")), + transferConnection = Some( + ZoneConnection("vinyldns.", "vinyldns.", "nzisn+4G2ldMn0q1CV3vsg==", "127.0.0.1:19001")) + ) + + "Obscuring Dns Messages" should { + "remove the tsig key value during an update" in { + val testRecord = aaaa.copy(zoneId = testZone.id) + val conn = DnsConnection(testZone.connection.get) + val result: DnsResponse = + rightResultOf(conn.addRecord(RecordSetChange.forAdd(testRecord, testZone)).run) + + result shouldBe a[NoError] + val resultingMessage = result.asInstanceOf[NoError].message + resultingMessage.getSectionArray(DNS.Section.ADDITIONAL) shouldBe empty + + val resultingMessageString = resultingMessage.toString + + resultingMessageString should not contain "TSIG" + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala new file mode 100644 index 0000000000..bfbf945c8d --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala @@ -0,0 +1,303 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain.record + +import com.typesafe.config.ConfigFactory +import org.joda.time.DateTime +import org.scalatest.Matchers +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.mockito.MockitoSugar +import org.scalatest.time.{Seconds, Span} +import scalaz.\/ +import vinyldns.api.domain.AccessValidations +import vinyldns.api.domain.auth.AuthPrincipal +import vinyldns.api.domain.membership.{Group, User, UserRepository} +import vinyldns.api.domain.record.RecordType._ +import vinyldns.api.domain.zone.{RecordSetAlreadyExists, Zone, ZoneRepository, ZoneStatus} +import vinyldns.api.engine.sqs.TestSqsService +import vinyldns.api.repository.dynamodb.{DynamoDBIntegrationSpec, DynamoDBRecordSetRepository} +import vinyldns.api.repository.mysql.VinylDNSJDBC + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class RecordSetServiceIntegrationSpec + extends DynamoDBIntegrationSpec + with MockitoSugar + with Matchers { + + private val recordSetTable = "recordSetTest" + + private val liveTestConfig = ConfigFactory.parseString(s""" + | recordSet { + | # use the dummy store, this should only be used local + | dummy = true + | + | dynamo { + | tableName = "$recordSetTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + | } + """.stripMargin) + + private val recordSetStoreConfig = liveTestConfig.getConfig("recordSet") + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + private var recordSetRepo: DynamoDBRecordSetRepository = _ + private var zoneRepo: ZoneRepository = _ + + private var testRecordSetService: RecordSetServiceAlgebra = _ + + private val user = User("live-test-user", "key", "secret") + private val group = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id)) + private val auth = AuthPrincipal(user, Seq(group.id)) + + private val zone = Zone( + s"live-zone-test.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection, + adminGroupId = group.id) + private val apexTestRecordA = RecordSet( + zone.id, + "live-zone-test", + A, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AData("10.1.1.1"))) + private val apexTestRecordAAAA = RecordSet( + zone.id, + "live-zone-test", + AAAA, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AAAAData("fd69:27cc:fe91::60"))) + private val subTestRecordA = RecordSet( + zone.id, + "a-record", + A, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AData("10.1.1.1"))) + private val subTestRecordAAAA = RecordSet( + zone.id, + "aaaa-record", + AAAA, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AAAAData("fd69:27cc:fe91::60"))) + private val subTestRecordNS = RecordSet( + zone.id, + "ns-record", + NS, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(NSData("172.17.42.1."))) + + private val zoneTestNameConflicts = Zone( + s"zone-test-name-conflicts.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection, + adminGroupId = group.id) + private val apexTestRecordNameConflict = RecordSet( + zoneTestNameConflicts.id, + "zone-test-name-conflicts.", + A, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AData("10.1.1.1"))) + private val subTestRecordNameConflict = RecordSet( + zoneTestNameConflicts.id, + "relative-name-conflict", + A, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AData("10.1.1.1"))) + + private val zoneTestAddRecords = Zone( + s"zone-test-add-records.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection, + adminGroupId = group.id) + + def setup(): Unit = { + recordSetRepo = new DynamoDBRecordSetRepository(recordSetStoreConfig, dynamoDBHelper) + zoneRepo = VinylDNSJDBC.instance.zoneRepository + + List(zone, zoneTestNameConflicts, zoneTestAddRecords).map(z => waitForSuccess(zoneRepo.save(z))) + + // Seeding records in DB + val records = List( + apexTestRecordA, + apexTestRecordAAAA, + subTestRecordA, + subTestRecordAAAA, + subTestRecordNS, + apexTestRecordNameConflict, + subTestRecordNameConflict) + records.map(record => waitForSuccess(recordSetRepo.putRecordSet(record))) + + testRecordSetService = new RecordSetService( + zoneRepo, + recordSetRepo, + mock[RecordChangeRepository], + mock[UserRepository], + TestSqsService, + new AccessValidations()) + } + + def tearDown(): Unit = () + + "DynamoDBRecordSetRepository" should { + "not alter record name when seeding database for tests" in { + val originalRecord = testRecordSetService + .getRecordSet(apexTestRecordA.id, apexTestRecordA.zoneId, auth) + .run + .mapTo[Throwable \/ RecordSet] + whenReady(originalRecord, timeout) { out => + rightValue(out).name shouldBe "live-zone-test" + } + } + } + + "RecordSetService" should { + "create apex record without trailing dot and save record name with trailing dot" in { + val newRecord = RecordSet( + zoneTestAddRecords.id, + "zone-test-add-records", + A, + 38400, + RecordSetStatus.Active, + DateTime.now, + None, + List(AData("10.1.1.1"))) + val result = + testRecordSetService.addRecordSet(newRecord, auth).run.mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + rightValue(out).recordSet.name shouldBe "zone-test-add-records." + } + } + + "update apex A record and add trailing dot" in { + val newRecord = apexTestRecordA.copy(ttl = 200) + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .run + .mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + val change = rightValue(out) + change.recordSet.name shouldBe "live-zone-test." + change.recordSet.ttl shouldBe 200 + } + } + + "update apex AAAA record and add trailing dot" in { + val newRecord = apexTestRecordAAAA.copy(ttl = 200) + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .run + .mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + val change = rightValue(out) + change.recordSet.name shouldBe "live-zone-test." + change.recordSet.ttl shouldBe 200 + } + } + + "update relative A record without adding trailing dot" in { + val newRecord = subTestRecordA.copy(ttl = 200) + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .run + .mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + val change = rightValue(out) + change.recordSet.name shouldBe "a-record" + change.recordSet.ttl shouldBe 200 + } + } + + "update relative AAAA without adding trailing dot" in { + val newRecord = subTestRecordAAAA.copy(ttl = 200) + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .run + .mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + val change = rightValue(out) + change.recordSet.name shouldBe "aaaa-record" + change.recordSet.ttl shouldBe 200 + } + } + + "update relative NS record without trailing dot" in { + val newRecord = subTestRecordNS.copy(ttl = 200) + val superAuth = AuthPrincipal(okGroupAuth.signedInUser.copy(isSuper = true), Seq.empty) + val result = testRecordSetService + .updateRecordSet(newRecord, superAuth) + .run + .mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + val change = rightValue(out) + change.recordSet.name shouldBe "ns-record" + change.recordSet.ttl shouldBe 200 + } + } + + "fail to add relative record if apex record with same name already exists" in { + val newRecord = apexTestRecordNameConflict.copy(name = "zone-test-name-conflicts") + val result = + testRecordSetService.addRecordSet(newRecord, auth).run.mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + leftValue(out) shouldBe a[RecordSetAlreadyExists] + } + } + + "fail to add apex record if relative record with same name already exists" in { + val newRecord = subTestRecordNameConflict.copy(name = "relative-name-conflict.") + val result = + testRecordSetService.addRecordSet(newRecord, auth).run.mapTo[Throwable \/ RecordSetChange] + whenReady(result, timeout) { out => + leftValue(out) shouldBe a[RecordSetAlreadyExists] + } + } + } + + private def waitForSuccess[T](f: => Future[T]): T = { + val waiting = f.recover { case _ => Thread.sleep(2000); waitForSuccess(f) } + Await.result[T](waiting, 15.seconds) + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/domain/zone/ZoneServiceIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/domain/zone/ZoneServiceIntegrationSpec.scala new file mode 100644 index 0000000000..c98aae1af5 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/domain/zone/ZoneServiceIntegrationSpec.scala @@ -0,0 +1,156 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain.zone + +import com.typesafe.config.ConfigFactory +import org.joda.time.DateTime +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.mockito.MockitoSugar +import org.scalatest.time.{Seconds, Span} +import scalaz.\/ +import vinyldns.api.domain.AccessValidations +import vinyldns.api.domain.auth.AuthPrincipal +import vinyldns.api.domain.membership.{Group, GroupRepository, User, UserRepository} +import vinyldns.api.domain.record._ +import vinyldns.api.engine.sqs.TestSqsService +import vinyldns.api.repository.dynamodb.{DynamoDBIntegrationSpec, DynamoDBRecordSetRepository} +import vinyldns.api.repository.mysql.VinylDNSJDBC + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class ZoneServiceIntegrationSpec extends DynamoDBIntegrationSpec with MockitoSugar { + + private val recordSetTable = "recordSetTest" + + private val liveTestConfig = ConfigFactory.parseString(s""" + | recordSet { + | # use the dummy store, this should only be used local + | dummy = true + | + | dynamo { + | tableName = "$recordSetTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + | } + """.stripMargin) + + private val recordSetStoreConfig = liveTestConfig.getConfig("recordSet") + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + private var recordSetRepo: RecordSetRepository = _ + private var zoneRepo: ZoneRepository = _ + + private var testZoneService: ZoneServiceAlgebra = _ + + private val user = User(s"live-test-user", "key", "secret") + private val group = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id)) + private val auth = AuthPrincipal(user, Seq(group.id)) + private val badAuth = AuthPrincipal(user, Seq()) + private val zone = Zone( + s"live-test-zone.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection, + adminGroupId = group.id) + + private val testRecordSOA = RecordSet( + zoneId = zone.id, + name = "vinyldns", + typ = RecordType.SOA, + ttl = 38400, + status = RecordSetStatus.Active, + created = DateTime.now, + records = + List(SOAData("172.17.42.1.", "admin.test.com.", 1439234395, 10800, 3600, 604800, 38400)) + ) + private val testRecordNS = RecordSet( + zoneId = zone.id, + name = "vinyldns", + typ = RecordType.NS, + ttl = 38400, + status = RecordSetStatus.Active, + created = DateTime.now, + records = List(NSData("172.17.42.1."))) + private val testRecordA = RecordSet( + zoneId = zone.id, + name = "jenkins", + typ = RecordType.A, + ttl = 38400, + status = RecordSetStatus.Active, + created = DateTime.now, + records = List(AData("10.1.1.1"))) + + private val changeSetSOA = ChangeSet(RecordSetChange.forAdd(testRecordSOA, zone)) + private val changeSetNS = ChangeSet(RecordSetChange.forAdd(testRecordNS, zone)) + private val changeSetA = ChangeSet(RecordSetChange.forAdd(testRecordA, zone)) + + def setup(): Unit = { + recordSetRepo = new DynamoDBRecordSetRepository(recordSetStoreConfig, dynamoDBHelper) + zoneRepo = VinylDNSJDBC.instance.zoneRepository + + waitForSuccess(zoneRepo.save(zone)) + // Seeding records in DB + waitForSuccess(recordSetRepo.apply(changeSetSOA)) + waitForSuccess(recordSetRepo.apply(changeSetNS)) + waitForSuccess(recordSetRepo.apply(changeSetA)) + + testZoneService = new ZoneService( + zoneRepo, + mock[GroupRepository], + mock[UserRepository], + mock[ZoneChangeRepository], + mock[ZoneConnectionValidator], + TestSqsService, + new ZoneValidations(1000), + new AccessValidations() + ) + } + + def tearDown(): Unit = () + + "ZoneEntity" should { + "reject a DeleteZone with bad auth" in { + val result = + testZoneService.deleteZone(zone.id, badAuth).run.mapTo[Throwable \/ ZoneCommandResult] + whenReady(result, timeout) { _ => + val error = leftResultOf(result) + error shouldBe a[NotAuthorizedError] + } + } + "accept a DeleteZone" in { + val removeARecord = ChangeSet(RecordSetChange.forDelete(testRecordA, zone)) + waitForSuccess(recordSetRepo.apply(removeARecord)) + + val result = testZoneService.deleteZone(zone.id, auth).run.mapTo[Throwable \/ ZoneChange] + whenReady(result, timeout) { out => + out.isRight shouldBe true + val change = out.toOption.get + change.zone.id shouldBe zone.id + change.changeType shouldBe ZoneChangeType.Delete + } + } + } + + private def waitForSuccess[T](f: => Future[T]): T = { + val waiting = f.recover { case _ => Thread.sleep(2000); waitForSuccess(f) } + Await.result[T](waiting, 15.seconds) + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/engine/ZoneCommandHandlerIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/engine/ZoneCommandHandlerIntegrationSpec.scala new file mode 100644 index 0000000000..d3b4f620b7 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/engine/ZoneCommandHandlerIntegrationSpec.scala @@ -0,0 +1,238 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.engine + +import java.util.concurrent.Executors + +import cats.effect.IO +import com.typesafe.config.ConfigFactory +import fs2.{Scheduler, Stream} +import org.joda.time.DateTime +import org.scalatest.concurrent.Eventually +import org.scalatest.time.{Millis, Seconds, Span} +import vinyldns.api.domain.batch.BatchChangeRepository +import vinyldns.api.domain.record._ +import vinyldns.api.domain.zone._ +import vinyldns.api.engine.sqs.SqsConnection +import vinyldns.api.repository.dynamodb.{ + DynamoDBIntegrationSpec, + DynamoDBRecordChangeRepository, + DynamoDBRecordSetRepository, + DynamoDBZoneChangeRepository +} +import vinyldns.api.repository.mysql.VinylDNSJDBC + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +class ZoneCommandHandlerIntegrationSpec extends DynamoDBIntegrationSpec with Eventually { + + import vinyldns.api.engine.sqs.SqsConverters._ + + private implicit val sched: Scheduler = + Scheduler.fromScheduledExecutorService(Executors.newScheduledThreadPool(2)) + + private val zoneName = "vinyldns." + private val zoneChangeTable = "zoneChangesTest" + private val recordSetTable = "recordSetTest" + private val recordChangeTable = "recordChangeTest" + + private val liveTestConfig = ConfigFactory.parseString(s""" + | zoneChanges { + | # use the dummy store, this should only be used local + | dummy = true + | + | dynamo { + | tableName = "$zoneChangeTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + | } + | recordSet { + | # use the dummy store, this should only be used local + | dummy = true + | + | dynamo { + | tableName = "$recordSetTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + | } + | recordChange { + | # use the dummy store, this should only be used local + | dummy = true + | + | dynamo { + | tableName = "$recordChangeTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + | } + """.stripMargin) + + private val zoneChangeStoreConfig = liveTestConfig.getConfig("zoneChanges") + private val recordSetStoreConfig = liveTestConfig.getConfig("recordSet") + private val recordChangeStoreConfig = liveTestConfig.getConfig("recordChange") + + private implicit val defaultPatience: PatienceConfig = + PatienceConfig(timeout = Span(5, Seconds), interval = Span(500, Millis)) + private implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.global + + private var recordChangeRepo: RecordChangeRepository = _ + private var recordSetRepo: RecordSetRepository = _ + private var zoneChangeRepo: ZoneChangeRepository = _ + private var zoneRepo: ZoneRepository = _ + private var batchChangeRepo: BatchChangeRepository = _ + private var sqsConn: SqsConnection = _ + private var str: Stream[IO, Unit] = _ + private val stopSignal = fs2.async.signalOf[IO, Boolean](false).unsafeRunSync() + + // Items to seed in DB + private val testZone = Zone( + zoneName, + "test@test.com", + ZoneStatus.Active, + connection = + Some(ZoneConnection("vinyldns.", "vinyldns.", "nzisn+4G2ldMn0q1CV3vsg==", "127.0.0.1:19001")), + transferConnection = + Some(ZoneConnection("vinyldns.", "vinyldns.", "nzisn+4G2ldMn0q1CV3vsg==", "127.0.0.1:19001")) + ) + private val inDbRecordSet = RecordSet( + zoneId = testZone.id, + name = "inDb", + typ = RecordType.A, + ttl = 38400, + status = RecordSetStatus.Active, + created = DateTime.now, + records = List(AData("1.2.3.4"))) + private val inDbRecordChange = ChangeSet(RecordSetChange.forSyncAdd(inDbRecordSet, testZone)) + private val inDbZoneChange = + ZoneChange.forUpdate(testZone.copy(email = "new@test.com"), testZone, okUserAuth) + + private val inDbRecordSetForSyncTest = RecordSet( + zoneId = testZone.id, + name = "vinyldns", + typ = RecordType.A, + ttl = 38400, + status = RecordSetStatus.Active, + created = DateTime.now, + records = List(AData("5.5.5.5"))) + private val inDbRecordChangeForSyncTest = ChangeSet( + RecordSetChange( + testZone, + inDbRecordSetForSyncTest, + okUserAuth.signedInUser.id, + RecordSetChangeType.Create, + RecordSetChangeStatus.Pending)) + + override def anonymize(recordSet: RecordSet): RecordSet = { + val fakeTime = new DateTime(2010, 1, 1, 0, 0) + recordSet.copy(id = "a", created = fakeTime, updated = None) + } + + def setup(): Unit = { + recordChangeRepo = new DynamoDBRecordChangeRepository(recordChangeStoreConfig, dynamoDBHelper) + recordSetRepo = new DynamoDBRecordSetRepository(recordSetStoreConfig, dynamoDBHelper) + zoneChangeRepo = new DynamoDBZoneChangeRepository(zoneChangeStoreConfig, dynamoDBHelper) + zoneRepo = VinylDNSJDBC.instance.zoneRepository + batchChangeRepo = VinylDNSJDBC.instance.batchChangeRepository + sqsConn = SqsConnection() + + //seed items database + waitForSuccess(zoneRepo.save(testZone)) + waitForSuccess(recordChangeRepo.save(inDbRecordChange)) + waitForSuccess(recordChangeRepo.save(inDbRecordChangeForSyncTest)) + waitForSuccess(recordSetRepo.apply(inDbRecordChange)) + waitForSuccess(recordSetRepo.apply(inDbRecordChangeForSyncTest)) + waitForSuccess(zoneChangeRepo.save(inDbZoneChange)) + // Run a noop query to make sure recordSetRepo is up + waitForSuccess(recordSetRepo.listRecordSets("1", None, None, None)) + + str = ZoneCommandHandler.mainFlow( + zoneRepo, + zoneChangeRepo, + recordSetRepo, + recordChangeRepo, + batchChangeRepo, + sqsConn, + 100.millis, + stopSignal) + str.compile.drain.unsafeRunAsync { _ => + () + } + } + + def tearDown(): Unit = { + stopSignal.set(true).unsafeRunSync() + Thread.sleep(2000) + } + + "ZoneCommandHandler" should { + "process a zone change" in { + val change = + ZoneChange.forUpdate(testZone.copy(email = "updated@test.com"), testZone, okUserAuth) + + sendCommand(change, sqsConn).unsafeRunSync() + eventually { + val getZone = zoneRepo.getZone(testZone.id) + whenReady(getZone) { zn => + zn.get.email shouldBe "updated@test.com" + } + } + } + + "process a recordset change" in { + val change = + RecordSetChange.forUpdate(inDbRecordSet, inDbRecordSet.copy(ttl = 1234), testZone) + sendCommand(change, sqsConn).unsafeRunSync() + eventually { + val getRs = recordSetRepo.getRecordSet(testZone.id, inDbRecordSet.id) + whenReady(getRs) { rs => + rs.get.ttl shouldBe 1234 + } + } + } + "process a zone sync" in { + val change = ZoneChange.forSync(testZone, okUserAuth) + + sendCommand(change, sqsConn).unsafeRunSync() + eventually { + val validatingQueries = for { + rs <- recordSetRepo.getRecordSet(testZone.id, inDbRecordSetForSyncTest.id) + ch <- recordChangeRepo.listRecordSetChanges(testZone.id) + } yield (rs, ch) + + whenReady(validatingQueries) { data => + val rs = data._1 + rs.get.name shouldBe "vinyldns." + + val updates = data._2 + val forThisRecord = updates.items.filter(_.recordSet.id == inDbRecordSetForSyncTest.id) + + forThisRecord.length shouldBe 2 + forThisRecord.exists(_.changeType == RecordSetChangeType.Create) shouldBe true + forThisRecord.exists(_.changeType == RecordSetChangeType.Update) shouldBe true + } + } + } + } + + private def waitForSuccess[T](f: => Future[T]): T = { + val waiting = f.recover { case _ => Thread.sleep(2000); waitForSuccess(f) } + Await.result[T](waiting, 15.seconds) + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupChangeRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupChangeRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..75b9a1a090 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupChangeRepositoryIntegrationSpec.scala @@ -0,0 +1,226 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import java.util +import java.util.Collections + +import com.amazonaws.services.dynamodbv2.model._ +import com.typesafe.config.ConfigFactory +import org.joda.time.DateTime +import org.scalatest.concurrent.{Eventually, PatienceConfiguration} +import org.scalatest.time.{Seconds, Span} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +class DynamoDBGroupChangeRepositoryIntegrationSpec extends DynamoDBIntegrationSpec with Eventually { + private implicit def dateTimeOrdering: Ordering[DateTime] = Ordering.fromLessThan(_.isAfter(_)) + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + + private val GROUP_CHANGES_TABLE = "group-changes-live" + + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$GROUP_CHANGES_TABLE" + | provisionedReads=30 + | provisionedWrites=30 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + private var repo: DynamoDBGroupChangeRepository = _ + + private val groupChanges = Seq(okGroupChange, okGroupChangeUpdate, okGroupChangeDelete) ++ + listOfDummyGroupChanges ++ listOfRandomTimeGroupChanges + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + repo = new DynamoDBGroupChangeRepository(tableConfig, dynamoDBHelper) + + // wait until the repo is ready, could take time if the table has to be created + var notReady = true + while (notReady) { + val result = Await.ready(repo.getGroupChange("any"), 5.seconds) + notReady = result.value.get.isFailure + Thread.sleep(2000) + } + + clearGroupChanges() + + // Create all the changes + val savedGroupChanges = Future.sequence(groupChanges.map(repo.save)) + + // Wait until all of the changes are done + Await.result(savedGroupChanges, 5.minutes) + } + + def tearDown(): Unit = { + + val request = new DeleteTableRequest().withTableName(GROUP_CHANGES_TABLE) + val deleteTables = dynamoDBHelper.deleteTable(request) + Await.ready(deleteTables, 100.seconds) + } + + private def clearGroupChanges(): Unit = { + + import scala.collection.JavaConverters._ + + val scanRequest = new ScanRequest().withTableName(GROUP_CHANGES_TABLE) + + val allGroupChanges = dynamoClient.scan(scanRequest).getItems.asScala.map(repo.fromItem) + + val batchWrites = allGroupChanges + .map { groupChange => + val key = new util.HashMap[String, AttributeValue]() + key.put("group_change_id", new AttributeValue(groupChange.id)) + new WriteRequest().withDeleteRequest(new DeleteRequest().withKey(key)) + } + .grouped(25) + .map { deleteRequests => + new BatchWriteItemRequest() + .withRequestItems(Collections.singletonMap(GROUP_CHANGES_TABLE, deleteRequests.asJava)) + } + .toList + + batchWrites.foreach { batch => + dynamoClient.batchWriteItem(batch) + } + } + + "DynamoDBGroupChangeRepository" should { + "get a group change by id" in { + val targetGroupChange = okGroupChange + whenReady(repo.getGroupChange(targetGroupChange.id), timeout) { retrieved => + retrieved shouldBe Some(targetGroupChange) + } + } + + "return none when no matching id is found" in { + whenReady(repo.getGroupChange("NotFound"), timeout) { retrieved => + retrieved shouldBe None + } + } + + "save a group change with oldGroup = None" in { + val targetGroupChange = okGroupChange + + val test = + for { + saved <- repo.save(targetGroupChange) + retrieved <- repo.getGroupChange(saved.id) + } yield retrieved + + whenReady(test, timeout) { saved => + saved shouldBe Some(targetGroupChange) + } + } + + "save a group change with oldGroup set" in { + val targetGroupChange = okGroupChangeUpdate + + val test = + for { + saved <- repo.save(targetGroupChange) + retrieved <- repo.getGroupChange(saved.id) + } yield retrieved + + whenReady(test, timeout) { saved => + saved shouldBe Some(targetGroupChange) + } + } + + "getGroupChanges should return the recent changes and the correct last key" in { + whenReady(repo.getGroupChanges(oneUserDummyGroup.id, None, 100), timeout) { retrieved => + retrieved.changes should contain theSameElementsAs listOfDummyGroupChanges.slice(0, 100) + retrieved.lastEvaluatedTimeStamp shouldBe Some( + listOfDummyGroupChanges(99).created.getMillis.toString) + } + } + + "getGroupChanges should start using the time startFrom" in { + whenReady( + repo.getGroupChanges( + oneUserDummyGroup.id, + Some(listOfDummyGroupChanges(50).created.getMillis.toString), + 100), + timeout) { retrieved => + retrieved.changes should contain theSameElementsAs listOfDummyGroupChanges.slice(51, 151) + retrieved.lastEvaluatedTimeStamp shouldBe Some( + listOfDummyGroupChanges(150).created.getMillis.toString) + } + } + + "getGroupChanges returns entire page and nextId = None if there are less than maxItems left" in { + whenReady( + repo.getGroupChanges( + oneUserDummyGroup.id, + Some(listOfDummyGroupChanges(200).created.getMillis.toString), + 100), + timeout) { retrieved => + retrieved.changes should contain theSameElementsAs listOfDummyGroupChanges.slice(201, 300) + retrieved.lastEvaluatedTimeStamp shouldBe None + } + } + + "getGroupChanges returns 3 pages of items" in { + val test = + for { + page1 <- repo.getGroupChanges(oneUserDummyGroup.id, None, 100) + page2 <- repo.getGroupChanges(oneUserDummyGroup.id, page1.lastEvaluatedTimeStamp, 100) + page3 <- repo.getGroupChanges(oneUserDummyGroup.id, page2.lastEvaluatedTimeStamp, 100) + page4 <- repo.getGroupChanges(oneUserDummyGroup.id, page3.lastEvaluatedTimeStamp, 100) + } yield (page1, page2, page3, page4) + whenReady(test, timeout) { retrieved => + retrieved._1.changes should contain theSameElementsAs listOfDummyGroupChanges.slice(0, 100) + retrieved._1.lastEvaluatedTimeStamp shouldBe Some( + listOfDummyGroupChanges(99).created.getMillis.toString) + retrieved._2.changes should contain theSameElementsAs listOfDummyGroupChanges.slice( + 100, + 200) + retrieved._2.lastEvaluatedTimeStamp shouldBe Some( + listOfDummyGroupChanges(199).created.getMillis.toString) + retrieved._3.changes should contain theSameElementsAs listOfDummyGroupChanges.slice( + 200, + 300) + retrieved._3.lastEvaluatedTimeStamp shouldBe Some( + listOfDummyGroupChanges(299).created.getMillis.toString) // the limit was reached before the end of list + retrieved._4.changes should contain theSameElementsAs List() // no matches found in the rest of the list + retrieved._4.lastEvaluatedTimeStamp shouldBe None + } + } + + "getGroupChanges should return `maxItem` items" in { + whenReady(repo.getGroupChanges(oneUserDummyGroup.id, None, 5), timeout) { retrieved => + retrieved.changes should contain theSameElementsAs listOfDummyGroupChanges.slice(0, 5) + retrieved.lastEvaluatedTimeStamp shouldBe Some( + listOfDummyGroupChanges(4).created.getMillis.toString) + } + } + + "getGroupChanges should handle changes inserted in random order" in { + // group changes have a random time stamp and inserted in random order + eventually(timeout) { + whenReady(repo.getGroupChanges(randomTimeGroup.id, None, 100), timeout) { retrieved => + val sorted = listOfRandomTimeGroupChanges.sortBy(_.created) + retrieved.changes should contain theSameElementsAs sorted.slice(0, 100) + retrieved.lastEvaluatedTimeStamp shouldBe Some(sorted(99).created.getMillis.toString) + } + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..09d4695cb7 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBGroupRepositoryIntegrationSpec.scala @@ -0,0 +1,238 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import java.util +import java.util.Collections + +import com.amazonaws.services.dynamodbv2.model._ +import com.typesafe.config.ConfigFactory +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} +import vinyldns.api.domain.membership.{Group, GroupStatus} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +class DynamoDBGroupRepositoryIntegrationSpec extends DynamoDBIntegrationSpec { + + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + + private val GROUP_TABLE = "groups-live" + + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$GROUP_TABLE" + | provisionedReads=30 + | provisionedWrites=30 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + private var repo: DynamoDBGroupRepository = _ + + private val activeGroups = + for (i <- 1 to 10) + yield + Group( + s"live-test-group$i", + s"test$i@test.com", + Some(s"description$i"), + memberIds = Set(s"member$i", s"member2$i"), + adminUserIds = Set(s"member$i", s"member2$i"), + id = "id-%03d".format(i) + ) + + private val inDbDeletedGroup = Group( + s"live-test-group-deleted", + s"test@test.com", + Some(s"description"), + memberIds = Set("member1"), + adminUserIds = Set("member1"), + id = "id-deleted-group", + status = GroupStatus.Deleted + ) + private val groups = activeGroups ++ List(inDbDeletedGroup) + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + repo = new DynamoDBGroupRepository(tableConfig, dynamoDBHelper) + + // wait until the repo is ready, could take time if the table has to be created + var notReady = true + while (notReady) { + val result = Await.ready(repo.getGroup("any"), 5.seconds) + notReady = result.value.get.isFailure + Thread.sleep(2000) + } + + clearGroups() + + // Create all the zones + val savedGroups = Future.sequence(groups.map(repo.save)) + + // Wait until all of the zones are done + Await.result(savedGroups, 5.minutes) + } + + def tearDown(): Unit = { + val request = new DeleteTableRequest().withTableName(GROUP_TABLE) + val deleteTables = dynamoDBHelper.deleteTable(request) + Await.ready(deleteTables, 100.seconds) + } + + private def clearGroups(): Unit = { + + import scala.collection.JavaConverters._ + + val scanRequest = new ScanRequest().withTableName(GROUP_TABLE) + + val allGroups = dynamoClient.scan(scanRequest).getItems.asScala.map(repo.fromItem) + + val batchWrites = allGroups + .map { group => + val key = new util.HashMap[String, AttributeValue]() + key.put("group_id", new AttributeValue(group.id)) + new WriteRequest().withDeleteRequest(new DeleteRequest().withKey(key)) + } + .grouped(25) + .map { deleteRequests => + new BatchWriteItemRequest() + .withRequestItems(Collections.singletonMap(GROUP_TABLE, deleteRequests.asJava)) + } + .toList + + batchWrites.foreach { batch => + dynamoClient.batchWriteItem(batch) + } + } + + "DynamoDBGroupRepository" should { + "get a group by id" in { + val targetGroup = groups.head + whenReady(repo.getGroup(targetGroup.id), timeout) { retrieved => + retrieved.get shouldBe targetGroup + } + } + + "get all active groups" in { + whenReady(repo.getAllGroups(), timeout) { retrieved => + retrieved shouldBe activeGroups.toSet + } + } + + "not return a deleted group when getting group by id" in { + val deleted = deletedGroup.copy(memberIds = Set("foo"), adminUserIds = Set("foo")) + val f = + for { + _ <- repo.save(deleted) + retrieved <- repo.getGroup(deleted.id) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe None + } + } + + "not return a deleted group when getting group by name" in { + val deleted = deletedGroup.copy(memberIds = Set("foo"), adminUserIds = Set("foo")) + val f = + for { + _ <- repo.save(deleted) + retrieved <- repo.getGroupByName(deleted.name) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe None + } + } + + "get groups should omit non existing groups" in { + val f = repo.getGroups(Set(activeGroups.head.id, "thisdoesnotexist")) + whenReady(f, timeout) { retrieved => + retrieved.map(_.id) should contain theSameElementsAs Set(activeGroups.head.id) + } + } + + "returns all the groups" in { + val f = repo.getGroups(groups.map(_.id).toSet) + + whenReady(f, timeout) { retrieved => + retrieved should contain theSameElementsAs activeGroups + } + } + + "only return requested groups" in { + val evenGroups = activeGroups.filter(_.id.takeRight(1).toInt % 2 == 0) + val f = repo.getGroups(evenGroups.map(_.id).toSet) + + whenReady(f, timeout) { retrieved => + retrieved should contain theSameElementsAs evenGroups + } + } + + "return an Empty set if nothing found" in { + val f = repo.getGroups(Set("notFound")) + + whenReady(f, timeout) { retrieved => + retrieved should contain theSameElementsAs Set() + } + } + + "not return deleted groups" in { + val deleted = deletedGroup.copy( + id = "test-deleted-group-get-groups", + memberIds = Set("foo"), + adminUserIds = Set("foo")) + val f = + for { + _ <- repo.save(deleted) + retrieved <- repo.getGroups(Set(deleted.id, groups.head.id)) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved.map(_.id) shouldBe Set(groups.head.id) + } + } + + "get a group by name" in { + val targetGroup = groups.head + whenReady(repo.getGroupByName(targetGroup.name), timeout) { retrieved => + retrieved.get shouldBe targetGroup + } + } + + "save a group with no description" in { + val group = Group( + "null-description", + "test@test.com", + None, + memberIds = Set("foo"), + adminUserIds = Set("bar")) + + val test = + for { + saved <- repo.save(group) + retrieved <- repo.getGroup(saved.id) + } yield retrieved + + whenReady(test, timeout) { saved => + saved.get.description shouldBe None + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBIntegrationSpec.scala new file mode 100644 index 0000000000..5bde7c604c --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBIntegrationSpec.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import java.util.UUID + +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest._ +import org.scalatest.concurrent.ScalaFutures +import org.slf4j.LoggerFactory +import vinyldns.api.domain.dns.DnsConversions +import vinyldns.api.{GroupTestData, ResultHelpers, VinylDNSTestData} + +trait DynamoDBIntegrationSpec + extends WordSpec + with BeforeAndAfterAll + with DnsConversions + with VinylDNSTestData + with GroupTestData + with ResultHelpers + with BeforeAndAfterEach + with Matchers + with ScalaFutures + with Inspectors { + + // this is defined in the docker/docker-compose.yml file for dynamodb + val port: Int = 19000 + val endpoint: String = s"http://localhost:$port" + + val dynamoConfig: Config = ConfigFactory.parseString(s""" + | key = "vinyldnsTest" + | secret = "notNeededForDynamoDbLocal" + | endpoint="$endpoint", + | region="us-east-1" + """.stripMargin) + val dynamoClient: AmazonDynamoDBClient = DynamoDBClient(dynamoConfig) + val dynamoDBHelper: DynamoDBHelper = + new DynamoDBHelper(dynamoClient, LoggerFactory.getLogger("DynamoDBIntegrationSpec")) + + override protected def beforeAll(): Unit = + setup() + + override protected def afterAll(): Unit = + tearDown() + + /* Allows a spec to initialize the database */ + def setup(): Unit + + /* Allows a spec to clean up */ + def tearDown(): Unit + + /* Generates a random string useful to avoid data collision */ + def genString: String = UUID.randomUUID().toString +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBMembershipRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBMembershipRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..2cb8507f2e --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBMembershipRepositoryIntegrationSpec.scala @@ -0,0 +1,179 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import com.typesafe.config.ConfigFactory +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +class DynamoDBMembershipRepositoryIntegrationSpec extends DynamoDBIntegrationSpec { + + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + private val membershipTable = "membership-live" + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$membershipTable" + | provisionedReads=100 + | provisionedWrites=100 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + private var repo: DynamoDBMembershipRepository = _ + + private val testUserIds = for (i <- 0 to 5) yield s"test-user-$i" + private val testGroupIds = for (i <- 0 to 5) yield s"test-group-$i" + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + repo = new DynamoDBMembershipRepository(tableConfig, dynamoDBHelper) + + // wait until the repo is ready, could take time if the table has to be created + var notReady = true + while (notReady) { + val result = Await.ready(repo.getGroupsForUser("any"), 5.seconds) + notReady = result.value.get.isFailure + Thread.sleep(2000) + } + + // Create all the items + val results = Future.sequence(testGroupIds.map(repo.addMembers(_, testUserIds.toSet))) + + // Wait until all of the data is stored + Await.result(results, 5.minutes) + } + + def tearDown(): Unit = { + val results = Future.sequence(testGroupIds.map(repo.removeMembers(_, testUserIds.toSet))) + + // Wait until all of the data is stored + Await.result(results, 5.minutes) + } + + "DynamoDBMembershipRepository" should { + val groupId = genString + val user1 = genString + val user2 = genString + "add members successfully" in { + whenReady(repo.addMembers(groupId, Set(user1, user2)), timeout) { memberIds => + memberIds should contain theSameElementsAs Set(user1, user2) + } + } + + "add a group to an existing user" in { + val group1 = genString + val group2 = genString + val user1 = genString + val f = + for { + _ <- repo.addMembers(group1, Set(user1)) + _ <- repo.addMembers(group2, Set(user1)) + userGroups <- repo.getGroupsForUser(user1) + } yield userGroups + + whenReady(f, timeout) { userGroups => + userGroups should contain theSameElementsAs Set(group1, group2) + } + } + + "return an empty set when getting groups for a user that does not exist" in { + whenReady(repo.getGroupsForUser("notHere"), timeout) { groupIds => + groupIds shouldBe empty + } + } + + "remove members successfully" in { + val group1 = genString + val group2 = genString + val user1 = genString + val f = + for { + _ <- repo.addMembers(group1, Set(user1)) + _ <- repo.addMembers(group2, Set(user1)) + _ <- repo.removeMembers(group1, Set(user1)) + userGroups <- repo.getGroupsForUser(user1) + } yield userGroups + + whenReady(f, timeout) { userGroups => + userGroups should contain theSameElementsAs Set(group2) + } + } + + "remove members not in group" in { + val group1 = genString + val user1 = genString + val user2 = genString + val f = + for { + _ <- repo.addMembers(group1, Set(user1)) + _ <- repo.removeMembers(group1, Set(user2)) + userGroups <- repo.getGroupsForUser(user2) + } yield userGroups + + whenReady(f, timeout) { userGroups => + userGroups shouldBe empty + } + } + + "remove all groups for user" in { + val group1 = genString + val group2 = genString + val group3 = genString + val user1 = genString + val f = + for { + _ <- repo.addMembers(group1, Set(user1)) + _ <- repo.addMembers(group2, Set(user1)) + _ <- repo.addMembers(group3, Set(user1)) + _ <- repo.removeMembers(group1, Set(user1)) + _ <- repo.removeMembers(group2, Set(user1)) + _ <- repo.removeMembers(group3, Set(user1)) + userGroups <- repo.getGroupsForUser(user1) + } yield userGroups + + whenReady(f, timeout) { userGroups => + userGroups shouldBe empty + } + } + + "retrieve all of the groups for a user" in { + val f = repo.getGroupsForUser(testUserIds.head) + + whenReady(f, timeout) { retrieved => + testGroupIds.foreach(groupId => retrieved should contain(groupId)) + } + } + + "remove members from a group" in { + val membersToRemove = testUserIds.toList.sorted.take(2).toSet + val groupsRemoved = testGroupIds.toList.sorted.take(2) + + val f = Future.sequence(groupsRemoved.map(repo.removeMembers(_, membersToRemove))) + + Await.result(f, 5.minutes) + + whenReady(repo.getGroupsForUser(membersToRemove.head), timeout) { groupsRetrieved => + forAll(groupsRetrieved) { groupId => + groupsRemoved should not contain groupId + } + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordChangeRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordChangeRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..fb2711183a --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordChangeRepositoryIntegrationSpec.scala @@ -0,0 +1,378 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import java.util +import java.util.UUID + +import com.amazonaws.services.dynamodbv2.model.{AttributeValue, DeleteItemRequest, ScanRequest} +import com.typesafe.config.ConfigFactory +import org.joda.time.DateTime +import org.scalatest.concurrent.{Eventually, PatienceConfiguration} +import org.scalatest.time.{Seconds, Span} +import vinyldns.api.domain.record.{ChangeSet, ChangeSetStatus, RecordSetChange} +import vinyldns.api.domain.zone.{Zone, ZoneStatus} +import vinyldns.api.domain.{record, zone} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext} + +class DynamoDBRecordChangeRepositoryIntegrationSpec + extends DynamoDBIntegrationSpec + with Eventually { + + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + private val recordChangeTable = "record-change-live" + + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$recordChangeTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + private var repo: DynamoDBRecordChangeRepository = _ + + private val user = abcAuth.signedInUser.userName + private val auth = abcAuth + + private val zoneA = Zone( + s"live-test-$user.zone-small.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection) + private val zoneB = zone.Zone( + s"live-test-$user.zone-large.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection) + + private val recordSetA = + for { + rsTemplate <- Seq(rsOk, aaaa, cname) + } yield + rsTemplate.copy( + zoneId = zoneA.id, + name = s"${rsTemplate.typ.toString}-${zoneA.account}.", + ttl = 100, + created = DateTime.now(), + id = UUID.randomUUID().toString + ) + + private val recordSetB = + for { + i <- 1 to 3 + } yield + rsOk.copy( + zoneId = zoneB.id, + name = s"${rsOk.typ.toString}-${zoneB.account}-$i.", + ttl = 100, + created = DateTime.now(), + id = UUID.randomUUID().toString + ) + + private val updateRecordSetA = + for { + rsTemplate <- Seq(rsOk, aaaa, cname) + } yield + rsTemplate.copy( + zoneId = zoneA.id, + name = s"${rsTemplate.typ.toString}-${zoneA.account}.", + ttl = 1000, + created = DateTime.now(), + id = UUID.randomUUID().toString + ) + + private val recordSetChangesA = { + for { + rs <- recordSetA + } yield RecordSetChange.forAdd(rs, zoneA, auth) + }.sortBy(_.id) + + private val recordSetChangesB = { + for { + rs <- recordSetB + } yield RecordSetChange.forAdd(rs, zoneB, auth) + }.sortBy(_.id) + + private val recordSetChangesC = { + for { + rs <- recordSetA + } yield RecordSetChange.forDelete(rs, zoneA, auth) + }.sortBy(_.id) + + private val recordSetChangesD = { + for { + rs <- recordSetA + updateRs <- updateRecordSetA + } yield RecordSetChange.forUpdate(rs, updateRs, zoneA) + }.sortBy(_.id) + + private val changeSetA = ChangeSet(recordSetChangesA) + private val changeSetB = record.ChangeSet(recordSetChangesB) + private val changeSetC = + record.ChangeSet(recordSetChangesC).copy(status = ChangeSetStatus.Applied) + private val changeSetD = record + .ChangeSet(recordSetChangesD) + .copy(createdTimestamp = changeSetA.createdTimestamp + 1000) // make sure D is created AFTER A + private val changeSets = List(changeSetA, changeSetB, changeSetC, changeSetD) + + //This zone is to test listing record changes in correct order + private val zoneC = zone.Zone( + s"live-test-$user.record-changes.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection) + private val baseTime = DateTime.now() + private val timeOrder = List( + baseTime.minusSeconds(8000), + baseTime.minusSeconds(7000), + baseTime.minusSeconds(6000), + baseTime.minusSeconds(5000), + baseTime.minusSeconds(4000), + baseTime.minusSeconds(3000), + baseTime.minusSeconds(2000), + baseTime.minusSeconds(1000), + baseTime + ) + + private val recordSetsC = + for { + rsTemplate <- Seq(rsOk, aaaa, cname) + } yield + rsTemplate.copy( + zoneId = zoneC.id, + name = s"${rsTemplate.typ.toString}-${zoneC.account}.", + ttl = 100, + id = UUID.randomUUID().toString + ) + + private val updateRecordSetsC = + for { + rsTemplate <- Seq(rsOk, aaaa, cname) + } yield + rsTemplate.copy( + zoneId = zoneC.id, + name = s"${rsTemplate.typ.toString}-${zoneC.account}.", + ttl = 1000, + id = UUID.randomUUID().toString + ) + + private val recordSetChangesCreateC = { + for { + (rs, index) <- recordSetsC.zipWithIndex + } yield RecordSetChange.forAdd(rs, zoneC, auth).copy(created = timeOrder(index)) + } + + private val recordSetChangesUpdateC = { + for { + (rs, index) <- recordSetsC.zipWithIndex + } yield + RecordSetChange + .forUpdate(rs, updateRecordSetsC(index), zoneC) + .copy(created = timeOrder(index + 3)) + } + + private val recordSetChangesDeleteC = { + for { + (rs, index) <- recordSetsC.zipWithIndex + } yield RecordSetChange.forDelete(rs, zoneC, auth).copy(created = timeOrder(index + 6)) + } + + private val changeSetCreateC = record.ChangeSet(recordSetChangesCreateC) + private val changeSetUpdateC = record.ChangeSet(recordSetChangesUpdateC) + private val changeSetDeleteC = record.ChangeSet(recordSetChangesDeleteC) + private val changeSetsC = List(changeSetCreateC, changeSetUpdateC, changeSetDeleteC) + private val recordSetChanges: List[RecordSetChange] = + (recordSetChangesCreateC ++ recordSetChangesUpdateC ++ recordSetChangesDeleteC) + .sortBy(_.created.getMillis) + .toList + .reverse // Changes are retrieved by time stamp in decending order + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + repo = new DynamoDBRecordChangeRepository(tableConfig, dynamoDBHelper) + + var notReady = true + while (notReady) { + val result = Await.ready(repo.getRecordSetChange("any", "any"), 5.seconds) + notReady = result.value.get.isFailure + } + + // Clear the table just in case there is some lagging test data + clearTable() + + changeSets.foreach { changeSet => + // Save the change set + val savedChangeSet = repo.save(changeSet) + + // Wait until all of the change sets are saved + Await.result(savedChangeSet, 5.minutes) + } + + changeSetsC.foreach { changeSet => + // Save the change set + val savedChangeSet = repo.save(changeSet) + + // Wait until all of the change sets are saved + Await.result(savedChangeSet, 5.minutes) + } + } + + def tearDown(): Unit = + clearTable() + + private def clearTable(): Unit = { + + import scala.collection.JavaConverters._ + + // clear the table that we work with here + // NOTE: This is brute force and could be cleaner + val scanRequest = new ScanRequest() + .withTableName(recordChangeTable) + + val result = + dynamoClient.scan(scanRequest).getItems.asScala.map(_.get(repo.RECORD_SET_CHANGE_ID).getS()) + + result.foreach(deleteItem) + } + + private def deleteItem(recordSetChangeId: String): Unit = { + val key = new util.HashMap[String, AttributeValue]() + key.put(repo.RECORD_SET_CHANGE_ID, new AttributeValue(recordSetChangeId)) + val request = new DeleteItemRequest().withTableName(recordChangeTable).withKey(key) + try { + dynamoClient.deleteItem(request) + } catch { + case ex: Throwable => + throw new UnexpectedDynamoResponseException(ex.getMessage, ex) + } + } + + "DynamoDBRepository" should { + "get a record change set by id" in { + val testRecordSetChange = pendingCreateAAAA.copy(id = genString) + + val f = + for { + saved <- repo.save(ChangeSet(Seq(testRecordSetChange))) + retrieved <- repo.getRecordSetChange(saved.zoneId, testRecordSetChange.id) + } yield retrieved + + whenReady(f, timeout) { result => + result shouldBe Some(testRecordSetChange) + } + } + + "get changes by zone id" in { + val f = repo.getChanges(zoneA.id) + whenReady(f, timeout) { result => + val sortedResults = result.map { changeSet => + changeSet.copy(changes = changeSet.changes.sortBy(_.id)) + } + sortedResults.size shouldBe 3 + sortedResults should contain(changeSetA) + sortedResults should contain(changeSetC) + sortedResults should contain(changeSetD) + } + } + + "get pending changes by zone id are sorted by earliest created timestamp" in { + val f = repo.getPendingChangeSets(zoneA.id) + whenReady(f, timeout) { result => + val sortedResults = result.map { changeSet => + changeSet.copy(changes = changeSet.changes.sortBy(_.id)) + } + sortedResults.size shouldBe 2 + sortedResults should contain(changeSetA) + sortedResults should contain(changeSetD) + sortedResults should not contain changeSetC + result.head.id should equal(changeSetA.id) + result(1).id should equal(changeSetD.id) + } + } + + "list all record set changes in zone C" in { + eventually { + val testFuture = repo.listRecordSetChanges(zoneC.id) + whenReady(testFuture, timeout) { result => + result.items shouldBe recordSetChanges + } + } + } + + "list record set changes with a page size of one" in { + val testFuture = repo.listRecordSetChanges(zoneC.id, maxItems = 1) + whenReady(testFuture, timeout) { result => + { + result.items shouldBe recordSetChanges.take(1) + } + } + } + + "list record set changes with page size of one and reuse key to get another page with size of two" in { + val testFuture = repo.listRecordSetChanges(zoneC.id, maxItems = 1) + whenReady(testFuture, timeout) { result => + { + val key = result.nextId + val testFuture2 = repo.listRecordSetChanges(zoneC.id, startFrom = key, maxItems = 2) + whenReady(testFuture2, timeout) { result => + { + val page2 = result.items + page2 shouldBe recordSetChanges.slice(1, 3) + } + } + } + } + } + + "return an empty list and nextId of None when passing last record as start" in { + val testFuture = repo.listRecordSetChanges(zoneC.id, maxItems = 9) + whenReady(testFuture, timeout) { result => + { + val key = result.nextId + val testFuture2 = repo.listRecordSetChanges(zoneC.id, startFrom = key) + whenReady(testFuture2, timeout) { result => + { + result.nextId shouldBe None + result.items shouldBe List() + } + } + } + } + } + + "have nextId of None when exhausting record changes" in { + val testFuture = repo.listRecordSetChanges(zoneC.id, maxItems = 10) + whenReady(testFuture, timeout) { result => + result.nextId shouldBe None + } + } + + "return empty list with startFrom of zero" in { + val testFuture = repo.listRecordSetChanges(zoneC.id, startFrom = Some("0")) + whenReady(testFuture, timeout) { result => + { + result.nextId shouldBe None + result.items shouldBe List() + } + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..951be6ca2b --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBRecordSetRepositoryIntegrationSpec.scala @@ -0,0 +1,488 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import java.util.UUID + +import com.amazonaws.services.dynamodbv2.model.{ScanRequest, ScanResult} +import com.typesafe.config.ConfigFactory +import org.joda.time.DateTime +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} +import vinyldns.api.domain.membership.User +import vinyldns.api.domain.record +import vinyldns.api.domain.record.{ChangeSet, ListRecordSetResults, RecordSet, RecordSetChange} +import vinyldns.api.domain.zone.{Zone, ZoneStatus} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +class DynamoDBRecordSetRepositoryIntegrationSpec + extends DynamoDBIntegrationSpec + with DynamoDBRecordSetConversions { + + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + private val recordSetTable = "record-sets-live" + private[repository] val recordSetTableName: String = recordSetTable + + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$recordSetTable" + | provisionedReads=50 + | provisionedWrites=50 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + import dynamoDBHelper._ + + private val repo = new DynamoDBRecordSetRepository(tableConfig, dynamoDBHelper) + + private val users = for (i <- 1 to 3) + yield User(s"live-test-acct$i", "key", "secret") + + private val zones = + for { + acct <- users + i <- 1 to 3 + } yield + Zone( + s"live-test-${acct.userName}.zone$i.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection) + + private val rsTemplates = Seq(rsOk, aaaa, cname) + + private val rsQualifiedStatus = Seq("-dotless", "-dotted.") + + private val recordSets = + for { + zone <- zones + rsTemplate <- rsTemplates + rsQualifiedStatus <- rsQualifiedStatus + } yield + rsTemplate.copy( + zoneId = zone.id, + name = s"${rsTemplate.typ.toString}-${zone.account}$rsQualifiedStatus", + ttl = 100, + created = DateTime.now(), + id = UUID.randomUUID().toString + ) + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + + // wait until the repo is ready, could take time if the table has to be created + var notReady = true + while (notReady) { + val result = Await.ready( + repo.listRecordSets( + zoneId = "any", + startFrom = None, + maxItems = None, + recordNameFilter = None), + 5.seconds) + notReady = result.value.get.isFailure + Thread.sleep(1000) + } + + // Clear the zone just in case there is some lagging test data + clearTable() + + // Create all the zones + val savedRecordSets = Future.sequence(recordSets.map(repo.putRecordSet)) + + // Wait until all of the zones are done + Await.result(savedRecordSets, 5.minutes) + } + + def tearDown(): Unit = + clearTable() + + private def clearTable(): Unit = { + + import scala.collection.JavaConverters._ + + // clear all the zones from the table that we work with here + val scanRequest = new ScanRequest().withTableName(recordSetTable) + + val scanResult = dynamoClient.scan(scanRequest) + + var counter = 0 + + def delete(r: ScanResult) { + val result = r.getItems.asScala.grouped(25) + + // recurse over the results of the scan, convert each group to a BatchWriteItem with Deletes, and then delete + // using a blocking call + result.foreach { group => + val recordSetIds = group.map(_.get(DynamoDBRecordSetRepository.RECORD_SET_ID).getS) + val deletes = recordSetIds.map(deleteRecordSetFromTable) + val batchDelete = toBatchWriteItemRequest(deletes, recordSetTable) + + dynamoClient.batchWriteItem(batchDelete) + + counter = counter + 25 + } + + if (r.getLastEvaluatedKey != null && !r.getLastEvaluatedKey.isEmpty) { + val nextScan = new ScanRequest().withTableName(recordSetTable) + nextScan.setExclusiveStartKey(scanResult.getLastEvaluatedKey) + val nextScanResult = dynamoClient.scan(scanRequest) + delete(nextScanResult) + } + } + + delete(scanResult) + } + + "DynamoDBRepository" should { + "get a record set by id" in { + val testRecordSet = recordSets.head + val testFuture = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = None, + recordNameFilter = None) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet.recordSets should contain(testRecordSet) + } + } + + "get a record set count" in { + val testRecordSet = recordSets.head + val expected = 6 + val testFuture = repo.getRecordSetCount(testRecordSet.zoneId) + whenReady(testFuture, timeout) { foundRecordSetCount => + foundRecordSetCount shouldBe expected + } + } + + "get a record set by record set id and zone id" in { + val testRecordSet = recordSets.head + val testFuture = repo.getRecordSet(testRecordSet.zoneId, testRecordSet.id) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe Some(testRecordSet) + } + } + + "get a record set by zone id, name, type" in { + val testRecordSet = recordSets.head + val testFuture = + repo.getRecordSets(testRecordSet.zoneId, testRecordSet.name, testRecordSet.typ) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a record set by zone id, case-insensitive name, type" in { + val testRecordSet = recordSets.head + val testFuture = repo.getRecordSets( + testRecordSet.zoneId, + testRecordSet.name.toUpperCase(), + testRecordSet.typ) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a fully qualified record set by zone id, trailing dot-insensitive name, type" in { + val testRecordSet = recordSets.find(_.name.endsWith(".")).get + val testFuture = + repo.getRecordSets(testRecordSet.zoneId, testRecordSet.name.dropRight(1), testRecordSet.typ) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a relative record set by zone id, trailing dot-insensitive name, type" in { + val testRecordSet = recordSets.find(_.name.endsWith("dotless")).get + val testFuture = + repo.getRecordSets(testRecordSet.zoneId, testRecordSet.name.concat("."), testRecordSet.typ) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a record set by zone id, name" in { + val testRecordSet = recordSets.head + val testFuture = repo.getRecordSetsByName(testRecordSet.zoneId, testRecordSet.name) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a record set by zone id, case-insensitive name" in { + val testRecordSet = recordSets.head + val testFuture = + repo.getRecordSetsByName(testRecordSet.zoneId, testRecordSet.name.toUpperCase()) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a fully qualified record set by zone id, trailing dot-insensitive name" in { + val testRecordSet = recordSets.find(_.name.endsWith(".")).get + val testFuture = + repo.getRecordSetsByName(testRecordSet.zoneId, testRecordSet.name.dropRight(1)) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "get a relative record set by zone id, trailing dot-insensitive name" in { + val testRecordSet = recordSets.find(_.name.endsWith("dotless")).get + val testFuture = + repo.getRecordSetsByName(testRecordSet.zoneId, testRecordSet.name.concat(".")) + whenReady(testFuture, timeout) { foundRecordSet => + foundRecordSet shouldBe List(testRecordSet) + } + } + + "list record sets with page size of 1 returns recordSets[0] only" in { + val testRecordSet = recordSets.head + val testFuture = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = Some(1), + recordNameFilter = None) + whenReady(testFuture, timeout) { foundRecordSet => + { + foundRecordSet.recordSets should contain(recordSets(0)) + foundRecordSet.recordSets shouldNot contain(recordSets(1)) + foundRecordSet.nextId.get.split('~')(2) shouldBe recordSets(0).id + } + } + } + + "list record sets with page size of 1 reusing key with page size of 1 returns recordSets[0] and recordSets[1]" in { + val testRecordSet = recordSets.head + val testFutureOne = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = Some(1), + recordNameFilter = None) + whenReady(testFutureOne, timeout) { foundRecordSet => + { + foundRecordSet.recordSets should contain(recordSets(0)) + foundRecordSet.recordSets shouldNot contain(recordSets(1)) + val key = foundRecordSet.nextId + val testFutureTwo = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = key, + maxItems = Some(1), + recordNameFilter = None) + whenReady(testFutureTwo, timeout) { foundRecordSet => + { + foundRecordSet.recordSets shouldNot contain(recordSets(0)) + foundRecordSet.recordSets should contain(recordSets(1)) + foundRecordSet.recordSets shouldNot contain(recordSets(2)) + foundRecordSet.nextId.get.split('~')(2) shouldBe recordSets(1).id + } + } + } + } + } + + "list record sets page size of 1 then reusing key with page size of 2 returns recordSets[0], recordSets[1,2]" in { + val testRecordSet = recordSets.head + val testFutureOne = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = Some(1), + recordNameFilter = None) + whenReady(testFutureOne, timeout) { foundRecordSet => + { + foundRecordSet.recordSets should contain(recordSets(0)) + foundRecordSet.recordSets shouldNot contain(recordSets(1)) + val key = foundRecordSet.nextId + val testFutureTwo = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = key, + maxItems = Some(2), + recordNameFilter = None) + whenReady(testFutureTwo, timeout) { foundRecordSet => + { + foundRecordSet.recordSets shouldNot contain(recordSets(0)) + foundRecordSet.recordSets should contain(recordSets(1)) + foundRecordSet.recordSets should contain(recordSets(2)) + foundRecordSet.nextId.get.split('~')(2) shouldBe recordSets(2).id + } + } + } + } + } + + "return an empty list and nextId of None when passing last record as start" in { + val testRecordSet = recordSets.head + val testFutureOne = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = Some(6), + recordNameFilter = None) + whenReady(testFutureOne, timeout) { foundRecordSet => + { + foundRecordSet.recordSets should contain(recordSets(0)) + foundRecordSet.recordSets should contain(recordSets(1)) + foundRecordSet.recordSets should contain(recordSets(2)) + foundRecordSet.recordSets should contain(recordSets(3)) + foundRecordSet.recordSets should contain(recordSets(4)) + foundRecordSet.recordSets should contain(recordSets(5)) + val key = foundRecordSet.nextId + val testFutureTwo = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = key, + maxItems = Some(6), + recordNameFilter = None) + whenReady(testFutureTwo, timeout) { foundRecordSet => + { + foundRecordSet.recordSets shouldBe List() + foundRecordSet.nextId shouldBe None + } + } + } + } + } + + "have nextId of None when exhausting recordSets" in { + val testRecordSet = recordSets.head + val testFuture = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = Some(7), + recordNameFilter = None) + whenReady(testFuture, timeout) { foundRecordSet => + { + foundRecordSet.recordSets should contain(recordSets(0)) + foundRecordSet.recordSets should contain(recordSets(1)) + foundRecordSet.recordSets should contain(recordSets(2)) + foundRecordSet.recordSets should contain(recordSets(3)) + foundRecordSet.recordSets should contain(recordSets(4)) + foundRecordSet.recordSets should contain(recordSets(5)) + foundRecordSet.nextId shouldBe None + } + } + } + + "only retrieve recordSet with name containing 'AAAA'" in { + val testRecordSet = recordSets.head + val testFuture = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = None, + recordNameFilter = Some("AAAA")) + whenReady(testFuture, timeout) { foundRecordSet => + { + foundRecordSet.recordSets shouldNot contain(recordSets(0)) + foundRecordSet.recordSets shouldNot contain(recordSets(1)) + foundRecordSet.recordSets should contain(recordSets(2)) + foundRecordSet.recordSets should contain(recordSets(3)) + } + } + } + + "retrieve all recordSets with names containing 'A'" in { + val testRecordSet = recordSets.head + val testFuture = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = None, + recordNameFilter = Some("A")) + whenReady(testFuture, timeout) { foundRecordSet => + { + foundRecordSet.recordSets should contain(recordSets(0)) + foundRecordSet.recordSets should contain(recordSets(1)) + foundRecordSet.recordSets should contain(recordSets(2)) + foundRecordSet.recordSets should contain(recordSets(3)) + foundRecordSet.recordSets should contain(recordSets(4)) + foundRecordSet.recordSets should contain(recordSets(5)) + } + } + } + + "return an empty list if recordName filter had no match" in { + val testRecordSet = recordSets.head + val testFuture = repo.listRecordSets( + zoneId = testRecordSet.zoneId, + startFrom = None, + maxItems = None, + recordNameFilter = Some("Dummy")) + whenReady(testFuture, timeout) { foundRecordSet => + { + foundRecordSet.recordSets shouldBe List() + } + } + } + + "apply a change set" in { + val newRecordSets = + for { + i <- 1 to 1000 + } yield + aaaa.copy( + zoneId = "big-apply-zone", + name = s"$i.apply.test.", + id = UUID.randomUUID().toString) + + val pendingChanges = newRecordSets.map(RecordSetChange.forAdd(_, zones.head, okAuth)) + val bigPendingChangeSet = ChangeSet(pendingChanges) + + try { + val f = repo.apply(bigPendingChangeSet) + Await.result(f, 1500.seconds) + + // let's fail half of them + val split = pendingChanges.grouped(pendingChanges.length / 2).toSeq + val halfSuccess = split.head.map(_.successful) + val halfFailed = split(1).map(_.failed()) + val halfFailedChangeSet = record.ChangeSet(halfSuccess ++ halfFailed) + + val nextUp = repo.apply(halfFailedChangeSet) + Await.result(nextUp, 1500.seconds) + + // let's run our query and see how long until we succeed(which will determine + // how long it takes DYNAMO to update its index) + var querySuccessful = false + var retries = 1 + var recordSetsResult: List[RecordSet] = Nil + while (!querySuccessful && retries <= 10) { + // if we query now, we should get half that failed + val rsQuery = repo.listRecordSets( + zoneId = "big-apply-zone", + startFrom = None, + maxItems = None, + recordNameFilter = None) + recordSetsResult = Await.result[ListRecordSetResults](rsQuery, 30.seconds).recordSets + querySuccessful = recordSetsResult.length == halfSuccess.length + retries += 1 + Thread.sleep(100) + } + + querySuccessful shouldBe true + + // the result of the query should be the same as those pending that succeeded + val expected = halfSuccess.map(_.recordSet) + recordSetsResult should contain theSameElementsAs expected + } catch { + case e: Throwable => + e.printStackTrace() + fail("encountered error running apply test") + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBUserRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBUserRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..2f9dedbda8 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBUserRepositoryIntegrationSpec.scala @@ -0,0 +1,189 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import com.amazonaws.services.dynamodbv2.model.DeleteTableRequest +import com.typesafe.config.ConfigFactory +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} +import vinyldns.api.domain.membership.User + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} + +class DynamoDBUserRepositoryIntegrationSpec extends DynamoDBIntegrationSpec { + + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + + private val userTable = "users-live" + + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$userTable" + | provisionedReads=100 + | provisionedWrites=100 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + private var repo: DynamoDBUserRepository = _ + + private val testUserIds = (for { i <- 0 to 100 } yield s"test-user-$i").toList.sorted + private val users = testUserIds.map { id => + User(id = id, userName = "name" + id, accessKey = s"abc$id", secretKey = "123") + } + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + repo = new DynamoDBUserRepository(tableConfig, dynamoDBHelper) + + // wait until the repo is ready, could take time if the table has to be created + var notReady = true + while (notReady) { + val result = Await.ready(repo.getUser("any"), 5.seconds) + notReady = result.value.get.isFailure + Thread.sleep(2000) + } + + // Create all the items + val results = Future.sequence(users.map(repo.save(_))) + + // Wait until all of the data is stored + Await.result(results, 5.minutes) + } + + def tearDown(): Unit = { + val request = new DeleteTableRequest().withTableName(userTable) + val deleteTables = dynamoDBHelper.deleteTable(request) + Await.ready(deleteTables, 100.seconds) + } + + "DynamoDBUserRepository" should { + "retrieve a user" in { + val f = repo.getUser(testUserIds.head) + + whenReady(f, timeout) { retrieved => + retrieved shouldBe Some(users.head) + } + } + "returns None when the user does not exist" in { + val f = repo.getUser("does not exists") + + whenReady(f, timeout) { retrieved => + retrieved shouldBe None + } + } + "getUsers omits all non existing users" in { + val getUsers = + for { + result <- repo.getUsers(Set("notFound", testUserIds.head), None, Some(100)) + } yield result + whenReady(getUsers, timeout) { result => + result.users.map(_.id) should contain theSameElementsAs Set(testUserIds.head) + result.users.map(_.id) should not contain "notFound" + } + } + "returns all the users" in { + val f = repo.getUsers(testUserIds.toSet, None, None) + + whenReady(f, timeout) { retrieved => + retrieved.users should contain theSameElementsAs users + retrieved.lastEvaluatedId shouldBe None + } + } + "only return requested users" in { + val evenUsers = users.filter(_.id.takeRight(1).toInt % 2 == 0) + val f = repo.getUsers(evenUsers.map(_.id).toSet, None, None) + + whenReady(f, timeout) { retrieved => + retrieved.users should contain theSameElementsAs evenUsers + retrieved.lastEvaluatedId shouldBe None + } + } + "start at the exclusive start key" in { + val f = repo.getUsers(testUserIds.toSet, Some(testUserIds(5)), None) + + whenReady(f, timeout) { retrieved => + retrieved.users should not contain users(5) //start key is exclusive + retrieved.users should contain theSameElementsAs users.slice(6, users.length) + retrieved.lastEvaluatedId shouldBe None + } + } + "only return the number of items equal to the limit" in { + val f = repo.getUsers(testUserIds.toSet, None, Some(5)) + + whenReady(f, timeout) { retrieved => + retrieved.users.size shouldBe 5 + retrieved.users should contain theSameElementsAs users.take(5) + } + } + "returns the correct lastEvaluatedKey" in { + val f = repo.getUsers(testUserIds.toSet, None, Some(5)) + + whenReady(f, timeout) { retrieved => + retrieved.lastEvaluatedId shouldBe Some(users(4).id) // base 0 + retrieved.users should contain theSameElementsAs users.take(5) + } + } + "return the user if the matching access key" in { + val f = repo.getUserByAccessKey(users.head.accessKey) + + whenReady(f, timeout) { retrieved => + retrieved shouldBe Some(users.head) + } + } + "returns None not user has a matching access key" in { + val f = repo.getUserByAccessKey("does not exists") + + whenReady(f, timeout) { retrieved => + retrieved shouldBe None + } + } + "returns the super user flag when true" in { + val testUser = User( + userName = "testSuper", + accessKey = "testSuper", + secretKey = "testUser", + isSuper = true) + + val f = + for { + saved <- repo.save(testUser) + result <- repo.getUser(saved.id) + } yield result + + whenReady(f, timeout) { saved => + saved shouldBe Some(testUser) + saved.get.isSuper shouldBe true + } + } + "returns the super user flag when false" in { + val testUser = User(userName = "testSuper", accessKey = "testSuper", secretKey = "testUser") + + val f = + for { + saved <- repo.save(testUser) + result <- repo.getUser(saved.id) + } yield result + + whenReady(f, timeout) { saved => + saved shouldBe Some(testUser) + saved.get.isSuper shouldBe false + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBZoneChangeRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBZoneChangeRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..9b45013e10 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/dynamodb/DynamoDBZoneChangeRepositoryIntegrationSpec.scala @@ -0,0 +1,225 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.dynamodb + +import java.util + +import com.amazonaws.services.dynamodbv2.model.{AttributeValue, DeleteItemRequest, ScanRequest} +import com.typesafe.config.ConfigFactory +import org.joda.time.DateTime +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} +import vinyldns.api.domain.membership.User +import vinyldns.api.domain.zone._ + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} +import scala.util.Random + +class DynamoDBZoneChangeRepositoryIntegrationSpec extends DynamoDBIntegrationSpec { + + private implicit val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + + private val zoneChangeTable = "zone-changes-live" + + private val tableConfig = ConfigFactory.parseString(s""" + | dynamo { + | tableName = "$zoneChangeTable" + | provisionedReads=30 + | provisionedWrites=30 + | } + """.stripMargin).withFallback(ConfigFactory.load()) + + private var repo: DynamoDBZoneChangeRepository = _ + + private val goodUser = User(s"live-test-acct", "key", "secret") + + private val okZones = for { i <- 1 to 3 } yield + Zone( + s"${goodUser.userName}.zone$i.", + "test@test.com", + status = ZoneStatus.Active, + connection = testConnection) + + private val zones = okZones + + private val statuses = { + import vinyldns.api.domain.zone.ZoneChangeStatus._ + Pending :: Complete :: Failed :: Synced :: Nil + } + private val changes = for { zone <- zones; status <- statuses } yield + ZoneChange( + zone, + zone.account, + ZoneChangeType.Update, + status, + created = now.minusSeconds(Random.nextInt(1000))) + + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + def setup(): Unit = { + repo = new DynamoDBZoneChangeRepository(tableConfig, dynamoDBHelper) + + // wait until the repo is ready, could take time if the table has to be created + var notReady = true + while (notReady) { + val result = Await.ready(repo.listZoneChanges("any"), 5.seconds) + notReady = result.value.get.isFailure + } + + // Clear the zone just in case there is some lagging test data + clearChanges() + + // Create all the zones + val savedChanges = Future.sequence(changes.map(repo.save)) + + // Wait until all of the zones are done + Await.result(savedChanges, 5.minutes) + } + + def tearDown(): Unit = + clearChanges() + + private def clearChanges(): Unit = { + + import scala.collection.JavaConverters._ + + // clear all the zones from the table that we work with here + // NOTE: This is brute force and could be cleaner + val scanRequest = new ScanRequest() + .withTableName(zoneChangeTable) + + val result = dynamoClient + .scan(scanRequest) + .getItems + .asScala + .map(i => (i.get("zone_id").getS, i.get("change_id").getS)) + + result.foreach(Function.tupled(deleteZoneChange)) + } + + private def deleteZoneChange(zoneId: String, changeId: String): Unit = { + val key = new util.HashMap[String, AttributeValue]() + key.put("zone_id", new AttributeValue(zoneId)) + key.put("change_id", new AttributeValue(changeId)) + val request = new DeleteItemRequest().withTableName(zoneChangeTable).withKey(key) + try { + dynamoClient.deleteItem(request) + } catch { + case ex: Throwable => + throw new UnexpectedDynamoResponseException(ex.getMessage, ex) + } + } + + "DynamoDBRepository" should { + + implicit def dateTimeOrdering: Ordering[DateTime] = Ordering.fromLessThan(_.isAfter(_)) + + "get all changes for a zone" in { + val testFuture = repo.listZoneChanges(okZones(1).id) + whenReady(testFuture, timeout) { retrieved => + val expectedChanges = changes.filter(_.zoneId == okZones(1).id).sortBy(_.created) + retrieved.items should equal(expectedChanges) + } + } + + "get pending and complete changes for a zone" in { + val testFuture = repo.getPending(okZones(1).id) + whenReady(testFuture, timeout) { retrieved => + val expectedChangeIds = changes + .filter(c => + c.zoneId == okZones(1).id + && (c.status == ZoneChangeStatus.Pending || c.status == ZoneChangeStatus.Complete)) + .map(_.id) + .toSet + + retrieved.map(_.id).toSet should contain theSameElementsAs expectedChangeIds + retrieved.sortBy(_.created.getMillis) should equal( + changes + .filter(c => + c.zoneId == okZones(1).id && + (c.status == ZoneChangeStatus.Pending || c.status == ZoneChangeStatus.Complete)) + .sortBy(_.created.getMillis)) + } + } + + "get zone changes with a page size of one" in { + val testFuture = repo.listZoneChanges(zoneId = okZones(1).id, startFrom = None, maxItems = 1) + whenReady(testFuture, timeout) { retrieved => + { + val result = retrieved.items + val expectedChanges = changes.filter(_.zoneId == okZones(1).id) + result.size shouldBe 1 + expectedChanges should contain(result.head) + } + } + } + + "get zone changes with page size of one and reuse key to get another page with size of two" in { + val testFuture = repo.listZoneChanges(zoneId = okZones(1).id, startFrom = None, maxItems = 1) + whenReady(testFuture, timeout) { retrieved => + { + val result1 = retrieved.items.map(_.id).toSet + val key = retrieved.nextId + val testFuture2 = + repo.listZoneChanges(zoneId = okZones(1).id, startFrom = key, maxItems = 2) + whenReady(testFuture2, timeout) { retrieved => + { + val result2 = retrieved.items + val expectedChanges = + changes.filter(_.zoneId == okZones(1).id).sortBy(_.created).slice(1, 3) + + result2.size shouldBe 2 + result2 should equal(expectedChanges) + result2 shouldNot contain(result1.head) + } + } + } + } + } + + "return an empty list and nextId of None when passing last record as start" in { + val testFuture = repo.listZoneChanges(zoneId = okZones(1).id, startFrom = None, maxItems = 4) + whenReady(testFuture, timeout) { retrieved => + { + val key = retrieved.nextId + val testFuture2 = repo.listZoneChanges(zoneId = okZones(1).id, startFrom = key) + whenReady(testFuture2, timeout) { retrieved => + { + val result2 = retrieved.items + result2 shouldBe List() + retrieved.nextId shouldBe None + } + } + } + } + } + + "have nextId of None when exhausting record changes" in { + val testFuture = repo.listZoneChanges(zoneId = okZones(1).id, startFrom = None, maxItems = 10) + whenReady(testFuture, timeout) { retrieved => + { + val result = retrieved.items + val expectedChanges = changes.filter(_.zoneId == okZones(1).id).sortBy(_.created) + result.size shouldBe 4 + result should equal(expectedChanges) + retrieved.nextId shouldBe None + } + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcBatchChangeRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcBatchChangeRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..4f939fee9b --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcBatchChangeRepositoryIntegrationSpec.scala @@ -0,0 +1,500 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.mysql + +import java.util.UUID + +import org.joda.time.DateTime +import org.scalatest._ +import org.scalatest.concurrent.{PatienceConfiguration, ScalaFutures} +import org.scalatest.time.{Seconds, Span} +import scalikejdbc.DB +import vinyldns.api.domain.auth.AuthPrincipal +import vinyldns.api.domain.batch._ +import vinyldns.api.domain.dns.DnsConversions +import vinyldns.api.domain.record.{AAAAData, AData} +import vinyldns.api.{GroupTestData, ResultHelpers, VinylDNSTestData} + +import scala.concurrent.{ExecutionContext, Future} + +class JdbcBatchChangeRepositoryIntegrationSpec + extends WordSpec + with BeforeAndAfterAll + with DnsConversions + with VinylDNSTestData + with GroupTestData + with ResultHelpers + with BeforeAndAfterEach + with Matchers + with ScalaFutures + with Inspectors + with OptionValues { + + private implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.global + private var repo: JdbcBatchChangeRepository = _ + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + import SingleChangeStatus._ + import vinyldns.api.domain.record.RecordType._ + + object TestData { + + val okAuth: AuthPrincipal = okGroupAuth + val notAuth: AuthPrincipal = dummyUserAuth + + val zoneID: String = "someZoneId" + val zoneName: String = "somezone.com." + + val sc1: SingleAddChange = + SingleAddChange( + zoneID, + zoneName, + "test", + "test.somezone.com.", + A, + 3600, + AData("1.2.3.4"), + Pending, + None, + None, + None) + + val sc2: SingleAddChange = + SingleAddChange( + zoneID, + zoneName, + "test", + "test.somezone.com.", + A, + 3600, + AData("1.2.3.40"), + Pending, + None, + None, + None) + + val sc3: SingleAddChange = + SingleAddChange( + zoneID, + zoneName, + "test", + "test.somezone.com.", + AAAA, + 300, + AAAAData("2001:558:feed:beef:0:0:0:1"), + Pending, + None, + None, + None) + + val deleteChange: SingleDeleteChange = + SingleDeleteChange( + zoneID, + zoneName, + "delete", + "delete.somezone.com.", + A, + Pending, + None, + None, + None) + + def randomBatchChange: BatchChange = BatchChange( + okAuth.userId, + okAuth.signedInUser.userName, + Some("description"), + DateTime.now, + List( + sc1.copy(id = UUID.randomUUID().toString), + sc2.copy(id = UUID.randomUUID().toString), + sc3.copy(id = UUID.randomUUID().toString), + deleteChange.copy(id = UUID.randomUUID().toString) + ) + ) + + val bcARecords: BatchChange = randomBatchChange + + def randomBatchChangeWithList(singlechanges: List[SingleChange]): BatchChange = + bcARecords.copy(id = UUID.randomUUID().toString, changes = singlechanges) + + val pendingBatchChange: BatchChange = randomBatchChange.copy(createdTimestamp = DateTime.now) + + val completeBatchChange: BatchChange = randomBatchChangeWithList( + randomBatchChange.changes.map(_.complete("recordChangeId", "recordSetId"))) + .copy(createdTimestamp = DateTime.now.plusMillis(1000)) + + val failedBatchChange: BatchChange = + randomBatchChangeWithList(randomBatchChange.changes.map(_.withFailureMessage("failed"))) + .copy(createdTimestamp = DateTime.now.plusMillis(100000)) + + val partialFailureBatchChange: BatchChange = randomBatchChangeWithList( + randomBatchChange.changes.take(2).map(_.complete("recordChangeId", "recordSetId")) + ++ randomBatchChange.changes.drop(2).map(_.withFailureMessage("failed")) + ).copy(createdTimestamp = DateTime.now.plusMillis(1000000)) + } + + import TestData._ + + override protected def beforeAll(): Unit = + repo = VinylDNSJDBC.instance.batchChangeRepository + + override protected def beforeEach(): Unit = + DB.localTx { s => + s.executeUpdate("DELETE FROM batch_change") + s.executeUpdate("DELETE FROM single_change") + } + + private def areSame(a: Option[BatchChange], e: Option[BatchChange]): Assertion = { + a shouldBe defined + e shouldBe defined + + val actual = a.get + val expected = e.get + + areSame(actual, expected) + } + + /* have to account for the database being different granularity than the JVM for DateTime */ + private def areSame(actual: BatchChange, expected: BatchChange): Assertion = { + (actual.changes should contain).theSameElementsInOrderAs(expected.changes) + actual.comments shouldBe expected.comments + actual.id shouldBe expected.id + actual.status shouldBe expected.status + actual.userId shouldBe expected.userId + actual.userName shouldBe expected.userId + actual.createdTimestamp.getMillis shouldBe expected.createdTimestamp.getMillis +- 2000 + } + + private def areSame(actual: BatchChangeSummary, expected: BatchChangeSummary): Assertion = { + actual.comments shouldBe expected.comments + actual.id shouldBe expected.id + actual.status shouldBe expected.status + actual.userId shouldBe expected.userId + actual.userName shouldBe expected.userId + actual.createdTimestamp.getMillis shouldBe expected.createdTimestamp.getMillis +- 2000 + } + + private def areSame( + actual: BatchChangeSummaryList, + expected: BatchChangeSummaryList): Assertion = { + forAll(actual.batchChanges.zip(expected.batchChanges)) { case (a, e) => areSame(a, e) } + actual.batchChanges.length shouldBe expected.batchChanges.length + actual.startFrom shouldBe expected.startFrom + actual.nextId shouldBe expected.nextId + actual.maxItems shouldBe expected.maxItems + } + + "JdbcBatchChangeRepository" should { + "save batch changes and single changes" in { + val f = repo.save(bcARecords) + whenReady(f, timeout) { saved => + saved shouldBe bcARecords + } + } + + "get a batchchange by id" in { + val f = + for { + _ <- repo.save(bcARecords) + retrieved <- repo.getBatchChange(bcARecords.id) + } yield retrieved + + whenReady(f, timeout) { retrieved => + areSame(retrieved, Some(bcARecords)) + } + } + + "return none if a batchchange is not found by id" in { + whenReady(repo.getBatchChange("doesnotexist"), timeout) { retrieved => + retrieved shouldBe empty + } + } + + "get singlechanges by list of id" in { + val f = + for { + _ <- repo.save(bcARecords) + retrieved <- repo.getSingleChanges(bcARecords.changes.map(_.id)) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe bcARecords.changes + } + } + + "not fail on get empty list of singlechanges" in { + val f = repo.getSingleChanges(List()) + + whenReady(f, timeout) { retrieved => + retrieved shouldBe List() + } + } + + "get single changes should match order from batch changes" in { + val batchChange = randomBatchChange + val f = + for { + _ <- repo.save(batchChange) + retrieved <- repo.getBatchChange(batchChange.id) + singleChanges <- retrieved + .map { r => + repo.getSingleChanges(r.changes.map(_.id).reverse) + } + .getOrElse(Future.successful[List[SingleChange]](Nil)) + } yield (retrieved, singleChanges) + + whenReady(f, timeout) { + case (maybeBatchChange, singleChanges) => + maybeBatchChange.value.changes shouldBe singleChanges + } + } + + "update singlechanges" in { + val batchChange = randomBatchChange + val completed = batchChange.changes.map(_.complete("aaa", "bbb")) + val f = + for { + _ <- repo.save(batchChange) + _ <- repo.updateSingleChanges(completed) + retrieved <- repo.getSingleChanges(completed.map(_.id)) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe completed + } + } + + "not fail on empty update singlechanges" in { + val f = repo.updateSingleChanges(List()) + + whenReady(f, timeout) { retrieved => + retrieved shouldBe List() + } + } + + "update some changes in a batch" in { + val batchChange = randomBatchChange + val completed = batchChange.changes.take(2).map(_.complete("recordChangeId", "recordSetId")) + val incomplete = batchChange.changes.drop(2) + val f = + for { + _ <- repo.save(batchChange) + _ <- repo.updateSingleChanges(completed) + retrieved <- repo.getSingleChanges(batchChange.changes.map(_.id)) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe completed ++ incomplete + } + } + + "get batchchange summary by user id" in { + val change_one = pendingBatchChange.copy(createdTimestamp = DateTime.now) + val change_two = completeBatchChange.copy(createdTimestamp = DateTime.now.plusMillis(1000)) + val otherUserBatchChange = + randomBatchChange.copy(userId = "Other", createdTimestamp = DateTime.now.plusMillis(50000)) + val change_three = failedBatchChange.copy(createdTimestamp = DateTime.now.plusMillis(100000)) + val change_four = + partialFailureBatchChange.copy(createdTimestamp = DateTime.now.plusMillis(1000000)) + + val f = + for { + _ <- repo.save(change_one) + _ <- repo.save(change_two) + _ <- repo.save(change_three) + _ <- repo.save(change_four) + _ <- repo.save(otherUserBatchChange) + + retrieved <- repo.getBatchChangeSummariesByUserId(pendingBatchChange.userId) + } yield retrieved + + // from most recent descending + val expectedChanges = BatchChangeSummaryList( + List( + BatchChangeSummary(change_four), + BatchChangeSummary(change_three), + BatchChangeSummary(change_two), + BatchChangeSummary(change_one)) + ) + + whenReady(f, timeout) { retrieved => + areSame(retrieved, expectedChanges) + } + } + + "get batchchange summary by user id with maxItems" in { + val change_one = pendingBatchChange.copy(createdTimestamp = DateTime.now) + val change_two = completeBatchChange.copy(createdTimestamp = DateTime.now.plusMillis(1000)) + val otherUserBatchChange = + randomBatchChange.copy(userId = "Other", createdTimestamp = DateTime.now.plusMillis(50000)) + val change_three = failedBatchChange.copy(createdTimestamp = DateTime.now.plusMillis(100000)) + val change_four = + partialFailureBatchChange.copy(createdTimestamp = DateTime.now.plusMillis(1000000)) + + val f = + for { + _ <- repo.save(change_one) + _ <- repo.save(change_two) + _ <- repo.save(change_three) + _ <- repo.save(change_four) + _ <- repo.save(otherUserBatchChange) + + retrieved <- repo.getBatchChangeSummariesByUserId(pendingBatchChange.userId, maxItems = 3) + } yield retrieved + + // from most recent descending + val expectedChanges = BatchChangeSummaryList( + List( + BatchChangeSummary(change_four), + BatchChangeSummary(change_three), + BatchChangeSummary(change_two)), + None, + Some(3), + 3 + ) + + whenReady(f, timeout) { retrieved => + areSame(retrieved, expectedChanges) + } + } + + "get batchchange summary by user id with explicit startFrom" in { + val timeBase = DateTime.now + val change_one = pendingBatchChange.copy(createdTimestamp = timeBase) + val change_two = completeBatchChange.copy(createdTimestamp = timeBase.plus(1000)) + val otherUserBatchChange = + randomBatchChange.copy(userId = "Other", createdTimestamp = timeBase.plus(50000)) + val change_three = failedBatchChange.copy(createdTimestamp = timeBase.plus(100000)) + val change_four = partialFailureBatchChange.copy(createdTimestamp = timeBase.plus(1000000)) + + val f = + for { + _ <- repo.save(change_one) + _ <- repo.save(change_two) + _ <- repo.save(change_three) + _ <- repo.save(change_four) + _ <- repo.save(otherUserBatchChange) + + retrieved <- repo.getBatchChangeSummariesByUserId( + pendingBatchChange.userId, + startFrom = Some(1), + maxItems = 3) + } yield retrieved + + // sorted from most recent descending. startFrom uses zero-based indexing. + // Expect to get only the second batch change, change_3. + // No nextId because the maxItems (3) equals the number of batch changes the user has after the offset (3) + val expectedChanges = BatchChangeSummaryList( + List( + BatchChangeSummary(change_three), + BatchChangeSummary(change_two), + BatchChangeSummary(change_one)), + Some(1), + None, + 3 + ) + + whenReady(f, timeout) { retrieved => + areSame(retrieved, expectedChanges) + } + } + + "get batchchange summary by user id with explicit startFrom and maxItems" in { + val timeBase = DateTime.now + val change_one = pendingBatchChange.copy(createdTimestamp = timeBase) + val change_two = completeBatchChange.copy(createdTimestamp = timeBase.plus(1000)) + val otherUserBatchChange = + randomBatchChange.copy(userId = "Other", createdTimestamp = timeBase.plus(50000)) + val change_three = failedBatchChange.copy(createdTimestamp = timeBase.plus(100000)) + val change_four = partialFailureBatchChange.copy(createdTimestamp = timeBase.plus(1000000)) + + val f = + for { + _ <- repo.save(change_one) + _ <- repo.save(change_two) + _ <- repo.save(change_three) + _ <- repo.save(change_four) + _ <- repo.save(otherUserBatchChange) + + retrieved <- repo.getBatchChangeSummariesByUserId( + pendingBatchChange.userId, + startFrom = Some(1), + maxItems = 1) + } yield retrieved + + // sorted from most recent descending. startFrom uses zero-based indexing. + // Expect to get only the second batch change, change_3. + // Expect the ID of the next batch change to be 2. + val expectedChanges = + BatchChangeSummaryList(List(BatchChangeSummary(change_three)), Some(1), Some(2), 1) + + whenReady(f, timeout) { retrieved => + areSame(retrieved, expectedChanges) + } + } + + "get second page of batchchange summaries by user id" in { + val timeBase = DateTime.now + val change_one = pendingBatchChange.copy(createdTimestamp = timeBase) + val change_two = completeBatchChange.copy(createdTimestamp = timeBase.plus(1000)) + val otherUserBatchChange = + randomBatchChange.copy(userId = "Other", createdTimestamp = timeBase.plus(50000)) + val change_three = failedBatchChange.copy(createdTimestamp = timeBase.plus(100000)) + val change_four = partialFailureBatchChange.copy(createdTimestamp = timeBase.plus(1000000)) + + val f = + for { + _ <- repo.save(change_one) + _ <- repo.save(change_two) + _ <- repo.save(change_three) + _ <- repo.save(change_four) + _ <- repo.save(otherUserBatchChange) + + retrieved1 <- repo.getBatchChangeSummariesByUserId( + pendingBatchChange.userId, + maxItems = 1) + retrieved2 <- repo.getBatchChangeSummariesByUserId( + pendingBatchChange.userId, + startFrom = retrieved1.nextId) + } yield (retrieved1, retrieved2) + + val expectedChanges = + BatchChangeSummaryList(List(BatchChangeSummary(change_four)), None, Some(1), 1) + + val secondPageExpectedChanges = BatchChangeSummaryList( + List( + BatchChangeSummary(change_three), + BatchChangeSummary(change_two), + BatchChangeSummary(change_one)), + Some(1), + None, + 100 + ) + + whenReady(f, timeout) { retrieved => + areSame(retrieved._1, expectedChanges) + areSame(retrieved._2, secondPageExpectedChanges) + } + } + + "return empty list if a batchchange summary is not found by user id" in { + whenReady(repo.getBatchChangeSummariesByUserId("doesnotexist"), timeout) { retrieved => + retrieved.batchChanges shouldBe empty + } + } + } +} diff --git a/modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcZoneRepositoryIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcZoneRepositoryIntegrationSpec.scala new file mode 100644 index 0000000000..a322e68813 --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/repository/mysql/JdbcZoneRepositoryIntegrationSpec.scala @@ -0,0 +1,559 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.repository.mysql + +import java.util.UUID + +import org.scalatest._ +import org.scalatest.concurrent.{PatienceConfiguration, ScalaFutures} +import org.scalatest.time.{Seconds, Span} +import scalikejdbc.DB +import vinyldns.api.domain.auth.AuthPrincipal +import vinyldns.api.domain.dns.DnsConversions +import vinyldns.api.domain.membership.User +import vinyldns.api.domain.zone._ +import vinyldns.api.{GroupTestData, ResultHelpers, VinylDNSTestData} + +import scala.concurrent.{ExecutionContext, Future} + +class JdbcZoneRepositoryIntegrationSpec + extends WordSpec + with BeforeAndAfterAll + with DnsConversions + with VinylDNSTestData + with GroupTestData + with ResultHelpers + with BeforeAndAfterEach + with Matchers + with ScalaFutures + with Inspectors { + + private implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.global + private var repo: JdbcZoneRepository = _ + private val timeout = PatienceConfiguration.Timeout(Span(10, Seconds)) + + override protected def beforeAll(): Unit = + repo = VinylDNSJDBC.instance.zoneRepository + + override protected def beforeEach(): Unit = + DB.localTx { s => + s.executeUpdate("DELETE FROM zone") + } + + private val groups = (0 until 10) + .map(num => okGroup.copy(name = num.toString, id = UUID.randomUUID().toString)) + .toList + + // We will add the dummy acl rule to only the first zone + private val dummyAclRule = + ACLRule( + accessLevel = AccessLevel.Read, + groupId = Some(dummyGroup.id) + ) + + // generate some ACLs + private val groupAclRules = groups.map( + g => + ACLRule( + accessLevel = AccessLevel.Read, + groupId = Some(g.id) + )) + + private val userOnlyAclRule = + ACLRule( + accessLevel = AccessLevel.Read, + userId = Some(okUser.id) + ) + + // the zone acl rule will have the user rule and all of the group rules + private val testZoneAcl = ZoneACL( + rules = Set(userOnlyAclRule) ++ groupAclRules + ) + + private val testZoneAdminGroupId = "foo" + + /** + * each zone will have an admin group id that doesn't exist, but have the ACL we generated above + * The okUser therefore should have access to all of the zones + */ + private val testZones = (1 until 10).map { num => + val z = + okZone.copy( + name = num.toString + ".", + id = UUID.randomUUID().toString, + adminGroupId = testZoneAdminGroupId, + acl = testZoneAcl + ) + + // add the dummy acl rule to the first zone + if (num == 1) z.addACLRule(dummyAclRule) else z + } + + private val superUserAuth = AuthPrincipal(dummyUser.copy(isSuper = true), Seq()) + + private def testZone(name: String, adminGroupId: String = testZoneAdminGroupId) = + okZone.copy(name = name, id = UUID.randomUUID().toString, adminGroupId = adminGroupId) + + private def saveZones(zones: Seq[Zone]): Future[Unit] = + zones.foldLeft(Future.successful(())) { + case (acc, cur) => + acc.flatMap { _ => + repo.save(cur).map(_ => ()) + } + } + + "JdbcZoneRepository" should { + "return the zone when it is saved" in { + whenReady(repo.save(okZone), timeout) { retrieved => + retrieved shouldBe okZone + } + } + + "get a zone by id" in { + val f = + for { + _ <- repo.save(okZone) + retrieved <- repo.getZone(okZone.id) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe Some(okZone) + } + } + + "return none if a zone is not found by id" in { + whenReady(repo.getZone("doesnotexist"), timeout) { retrieved => + retrieved shouldBe empty + } + } + + "get a zone by name" in { + val f = + for { + _ <- repo.save(okZone) + retrieved <- repo.getZoneByName(okZone.name) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved shouldBe Some(okZone) + } + } + + "return none if a zone is not found by name" in { + whenReady(repo.getZoneByName("doesnotexist"), timeout) { retrieved => + retrieved shouldBe empty + } + } + + "get a list of zones by names" in { + val f = saveZones(testZones) + val testZonesList1 = testZones.toList.take(3) + val testZonesList2 = testZones.toList.takeRight(5) + val names1 = testZonesList1.map(zone => zone.name) + val names2 = testZonesList2.map(zone => zone.name) + + whenReady(f, timeout) { _ => + whenReady(repo.getZonesByNames(names1.toSet), timeout) { retrieved => + retrieved should contain theSameElementsAs testZonesList1 + } + whenReady(repo.getZonesByNames(names2.toSet), timeout) { retrieved => + retrieved should contain theSameElementsAs testZonesList2 + } + } + } + + "return empty list if zones are not found by names" in { + whenReady( + repo.getZonesByNames(Set("doesnotexist", "doesnotexist2", "reallydoesnotexist")), + timeout) { retrieved => + retrieved shouldBe empty + } + } + + "get a list of reverse zones by zone names filters" in { + val testZones = Seq( + testZone("0/67.345.12.in-addr.arpa."), + testZone("67.345.12.in-addr.arpa."), + testZone("anotherZone.in-addr.arpa."), + testZone("extraZone.in-addr.arpa.") + ) + + val expectedZones = List(testZones(0), testZones(1), testZones(3)) + val f = saveZones(testZones) + + whenReady(f, timeout) { _ => + whenReady(repo.getZonesByFilters(Set("67.345.12.in-addr.arpa.", "extraZone")), timeout) { + retrieved => + retrieved should contain theSameElementsAs expectedZones + } + } + } + + "get authorized zones" in { + // store all of the zones + + val f = saveZones(testZones) + + // query for all zones for the ok user, he should have access to all of the zones + val okUserAuth = AuthPrincipal( + signedInUser = okUser, + memberGroupIds = groups.map(_.id) + ) + + whenReady(f, timeout) { _ => + whenReady(repo.listZones(okUserAuth), timeout) { retrieved => + retrieved should contain theSameElementsAs testZones + } + + // dummy user only has access to one zone + whenReady(repo.listZones(dummyUserAuth), timeout) { dummyZones => + (dummyZones should contain).only(testZones.head) + } + } + } + + "get zones that are accessible by everyone" in { + + //user and group id being set to None implies EVERYONE access + val allAccess = okZone.copy( + name = "all-access.", + id = UUID.randomUUID().toString, + acl = ZoneACL( + rules = Set( + ACLRule( + accessLevel = AccessLevel.Read, + userId = None, + groupId = None + ) + ) + ) + ) + + val noAccess = okZone.copy( + name = "no-access.", + id = UUID.randomUUID().toString, + adminGroupId = testZoneAdminGroupId, + acl = ZoneACL() + ) + + val testZones = Seq(allAccess, noAccess) + + val f = + for { + saved <- saveZones(testZones) + everyoneZones <- repo.listZones(dummyUserAuth) + } yield everyoneZones + + whenReady(f, timeout) { retrieved => + (retrieved should contain).only(allAccess) + } + } + + "not return deleted zones" in { + val zoneToDelete = okZone.copy( + name = "zone-to-delete.", + id = UUID.randomUUID().toString, + acl = ZoneACL( + rules = Set( + ACLRule( + accessLevel = AccessLevel.Read, + userId = None, + groupId = None + ) + ) + ) + ) + + // save it and make sure it is saved first by immediately getting it + val f = + for { + _ <- repo.save(zoneToDelete) + retrieved <- repo.getZone(zoneToDelete.id) + } yield retrieved + + whenReady(f, timeout) { saved => + // delete the zone, set the status to Deleted + val deleted = saved.map(_.copy(status = ZoneStatus.Deleted)).get + val del = + for { + _ <- repo.save(deleted) + retrieved <- repo.getZone(deleted.id) + } yield retrieved + + // the result should be None + whenReady(del, timeout) { retrieved => + retrieved shouldBe empty + } + } + } + + "return an empty list of zones if the user is not authorized to any" in { + val unauthorized = AuthPrincipal( + signedInUser = User("not-authorized", "not-authorized", "not-authorized"), + memberGroupIds = Seq.empty + ) + + val f = + for { + _ <- saveZones(testZones) + zones <- repo.listZones(unauthorized) + } yield zones + + whenReady(f, timeout) { retrieved => + retrieved shouldBe empty + } + } + + "not return zones when access is revoked" in { + // ok user can access both zones, dummy can only access first zone + val zones = testZones.take(2) + val addACL = saveZones(zones) + + val okUserAuth = AuthPrincipal( + signedInUser = okUser, + memberGroupIds = groups.map(_.id) + ) + + whenReady(addACL, timeout) { _ => + whenReady(repo.listZones(okUserAuth), timeout) { retrieved => + retrieved should contain theSameElementsAs zones + } + + // dummy user only has access to first zone + whenReady(repo.listZones(dummyUserAuth), timeout) { dummyZones => + (dummyZones should contain).only(zones.head) + } + + // revoke the access for the dummy user + val revoked = zones(0).deleteACLRule(dummyAclRule) + val revokeACL = repo.save(revoked) + + whenReady(revokeACL, timeout) { _ => + // ok user can still access zones + whenReady(repo.listZones(okUserAuth), timeout) { retrieved => + val expected = Seq(revoked, zones(1)) + retrieved should contain theSameElementsAs expected + } + + // dummy user can not access the revoked zone + whenReady(repo.listZones(dummyUserAuth), timeout) { dummyZones => + dummyZones shouldBe empty + } + } + } + } + + "omit zones for groups if the user has more than 30 groups" in { + + /** + * Somewhat complex setup. We only support 30 accessors right now, or 29 groups as the max + * number of groups a user belongs to. + * + * When we query for zones, we will truncate any groups over 29. + * + * So the test setup here creates 40 groups along with 40 zones, where the group id + * is the admin group of each zone. + * + * When we query, we should only get back 29 zones (the user id is always considered as an accessor id) + */ + val groups = (1 to 40).map { num => + val groupName = "%02d".format(num) + okGroup.copy( + name = groupName, + id = UUID.randomUUID().toString + ) + } + + val zones = groups.map { group => + val zoneName = group.name + "." + okZone.copy( + name = zoneName, + id = UUID.randomUUID().toString, + adminGroupId = group.id, + acl = ZoneACL() + ) + } + + val auth = AuthPrincipal(okUser, groups.map(_.id)) + + val f = + for { + _ <- saveZones(zones) + retrieved <- repo.listZones(auth) + } yield retrieved + + whenReady(f, timeout) { retrieved => + // we should not have more than 29 zones + retrieved.length shouldBe 29 + retrieved.headOption.map(_.name) shouldBe Some("01.") + retrieved.lastOption.map(_.name) shouldBe Some("29.") + } + } + + "return all zones if the user is a super user" in { + + val f = + for { + _ <- saveZones(testZones) + retrieved <- repo.listZones(superUserAuth) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved should contain theSameElementsAs testZones + } + } + + "apply the zone filter as a super user" in { + + val testZones = Seq( + testZone("system-test"), + testZone("system-temp"), + testZone("no-match") + ) + + val expectedZones = Seq(testZones(0), testZones(1)) + + val f = + for { + _ <- saveZones(testZones) + retrieved <- repo.listZones(superUserAuth, zoneNameFilter = Some("system")) + } yield retrieved + + whenReady(f, timeout) { retrieved => + retrieved should contain theSameElementsAs expectedZones + } + } + + "apply the zone filter as a normal user" in { + + val testZones = Seq( + testZone("system-test", adminGroupId = "foo"), + testZone("system-temp", adminGroupId = "foo"), + testZone("system-nomatch", adminGroupId = "bar") + ) + + val expectedZones = Seq(testZones(0), testZones(1)).sortBy(_.name) + + val auth = AuthPrincipal(dummyUser, Seq("foo")) + + val f = + for { + _ <- saveZones(testZones) + retrieved <- repo.listZones(auth, zoneNameFilter = Some("system")) + } yield retrieved + + whenReady(f, timeout) { retrieved => + (retrieved should contain).theSameElementsInOrderAs(expectedZones) + } + } + + "apply paging when searching as a super user" in { + // we have 10 zones in test zones, let's page through and check + val sorted = testZones.sortBy(_.name) + val expectedFirstPage = sorted.take(4) + val expectedSecondPage = sorted.drop(4).take(4) + val expectedThirdPage = sorted.drop(8).take(4) + + whenReady(saveZones(testZones), timeout) { _ => + whenReady(repo.listZones(superUserAuth, offset = None, pageSize = 4), timeout) { + firstPage => + (firstPage should contain).theSameElementsInOrderAs(expectedFirstPage) + } + + whenReady(repo.listZones(superUserAuth, offset = Some(4), pageSize = 4), timeout) { + secondPage => + (secondPage should contain).theSameElementsInOrderAs(expectedSecondPage) + } + + whenReady(repo.listZones(superUserAuth, offset = Some(8), pageSize = 4), timeout) { + thirdPage => + (thirdPage should contain).theSameElementsInOrderAs(expectedThirdPage) + } + } + } + + "apply paging when doing an authorized zone search" in { + // create 10 zones, but our user should only have access to 5 of them + val differentAdminGroupId = UUID.randomUUID().toString + + val testZones = (0 until 10).map { num => + val z = + okZone.copy( + name = num.toString + ".", + id = UUID.randomUUID().toString, + adminGroupId = testZoneAdminGroupId, + acl = ZoneACL() + ) + + // we are going to have 5 zones that havea different admin group id + if (num % 2 == 0) z.copy(adminGroupId = differentAdminGroupId) else z + } + + val sorted = testZones.sortBy(_.name) + val filtered = sorted.filter(_.adminGroupId == testZoneAdminGroupId) + val expectedFirstPage = filtered.take(2) + val expectedSecondPage = filtered.drop(2).take(2) + val expectedThirdPage = filtered.drop(4).take(2) + + // make sure our auth is a member of the testZoneAdminGroup + val auth = AuthPrincipal(dummyUser, Seq(testZoneAdminGroupId)) + + whenReady(saveZones(testZones), timeout) { _ => + whenReady(repo.listZones(auth, offset = None, pageSize = 2), timeout) { firstPage => + (firstPage should contain).theSameElementsInOrderAs(expectedFirstPage) + } + + whenReady(repo.listZones(auth, offset = Some(2), pageSize = 2), timeout) { secondPage => + (secondPage should contain).theSameElementsInOrderAs(expectedSecondPage) + } + + whenReady(repo.listZones(auth, offset = Some(4), pageSize = 2), timeout) { thirdPage => + (thirdPage should contain).theSameElementsInOrderAs(expectedThirdPage) + } + } + } + + "get zones by admin group" in { + val differentAdminGroupId = UUID.randomUUID().toString + + val testZones = (1 until 10).map { num => + val z = + okZone.copy( + name = num.toString + ".", + id = UUID.randomUUID().toString, + adminGroupId = testZoneAdminGroupId, + acl = testZoneAcl + ) + + // we are going to have 5 zones that have a different admin group id + if (num % 2 == 0) z.copy(adminGroupId = differentAdminGroupId) else z + } + + val expectedZones = testZones.filter(_.adminGroupId == differentAdminGroupId) + + val f = + for { + _ <- saveZones(testZones) + zones <- repo.getZonesByAdminGroupId(differentAdminGroupId) + } yield zones + + whenReady(f, timeout) { retrieved => + retrieved should contain theSameElementsAs expectedZones + } + } + } +} diff --git a/modules/api/src/main/protobuf/VinylDNSProto.proto b/modules/api/src/main/protobuf/VinylDNSProto.proto new file mode 100644 index 0000000000..963da3bd3a --- /dev/null +++ b/modules/api/src/main/protobuf/VinylDNSProto.proto @@ -0,0 +1,180 @@ +// VinylDNSProto.proto +option java_package = "vinyldns.proto"; +option optimize_for = SPEED; + +message ZoneConnection { + required string name = 1; + required string keyName = 2; + required string key = 3; + required string primaryServer = 4; +} + +message ACLRule { + required string accessLevel = 1; + optional string description = 2; + optional string userId = 3; + optional string groupId = 4; + optional string recordMask = 5; + repeated string recordTypes = 6; +} + +message ZoneACL { + repeated ACLRule rules = 1; +} + +message Zone { + required string id = 1; + required string name = 2; + required string email = 3; + required string status = 4; + required int64 created = 5; + optional int64 updated = 6; + optional ZoneConnection connection = 7; + required string account = 8; + optional bool shared = 9 [default = false]; + optional ZoneConnection transferConnection = 10; + optional ZoneACL acl = 11; + optional string adminGroupId = 12 [default = "system"]; + optional int64 latestSync = 13; +} + +message AData { + required string address = 1; +} + +message AAAAData { + required string address = 1; +} + +message CNAMEData { + required string cname = 1; +} + +message MXData { + required int32 preference = 1; + required string exchange = 2; +} + +message NSData { + required string nsdname = 1; +} + +message PTRData { + required string ptrdname = 1; +} + +message SOAData { + required string mname = 1; + required string rname = 2; + required int64 serial = 3; + required int64 refresh = 4; + required int64 retry = 5; + required int64 expire = 6; + required int64 minimum = 7; +} + +message SPFData { + required string text = 1; +} + +message SRVData { + required int32 priority = 1; + required int32 weight = 2; + required int32 port = 3; + required string target = 4; +} + +message SSHFPData { + required int32 algorithm = 1; + required int32 typ = 2; + required string fingerPrint = 3; +} + +message TXTData { + required string text = 1; +} + +message RecordData { + required bytes data = 1; +} + +message RecordSet { + required string zoneId = 1; + required string id = 2; + required string name = 3; + required string typ = 4; + required int64 ttl = 5; + required string status = 6; + required int64 created = 7; + optional int64 updated = 8; + repeated RecordData record = 9; + required string account = 10; +} + +message RecordSetChange { + required string id = 1; + required Zone zone = 2; + required RecordSet recordSet = 3; + required string userId = 4; + required string typ = 5; + required string status = 6; + required int64 created = 7; + optional string systemMessage = 8; + optional RecordSet updates = 9; + repeated string singleBatchChangeIds = 10; +} + +message ZoneChange { + required string id = 1; + required string userId = 2; + required string typ = 3; + required string status = 4; + required int64 created = 5; + required Zone zone = 6; + optional string systemMessage = 7; +} + +message Group { + required string id = 1; + required string name = 2; + required string email = 3; + required int64 created = 4; + required string status = 5; + repeated string memberIds = 6; + repeated string adminUserIds = 7; + optional string description = 8; +} + +message GroupChange { + required string groupChangeId = 1; + required string groupId = 2; + required string changeType = 3; + required string userId = 4; + required int64 created = 5; + required Group newGroup = 6; + optional Group oldGroup = 7; +} + +message SingleAddChange { + required int64 ttl = 1; + required RecordData recordData = 2; +} + +message SingleChangeData { + required bytes data = 1; +} + +message SingleChange { + required string id = 1; + required string status = 2; + required string zoneId = 3; + required string recordName = 4; + required string changeType = 5; + required string inputName = 6; + required string zoneName = 7; + required string recordType = 8; + optional string systemMessage = 9; + optional string recordChangeId = 10; + optional string recordSetId = 11; + optional SingleChangeData changeData = 12; +} diff --git a/modules/api/src/main/resources/application.conf b/modules/api/src/main/resources/application.conf new file mode 100644 index 0000000000..431fc4669d --- /dev/null +++ b/modules/api/src/main/resources/application.conf @@ -0,0 +1,66 @@ +################################################################################################################ +# This configuration is used primarily when running re-start or starting Vinyll locally. The configuration +# presumes a stand-alone Vinyll server with no backend services. +################################################################################################################ +akka { + loglevel = "ERROR" + + # The following settings are required to have Akka logging output to SLF4J and logback; without + # these, akka will output to STDOUT + loggers = ["akka.event.slf4j.Slf4jLogger"] + logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" + logger-startup-timeout = 30s + + actor { + provider = "akka.actor.LocalActorRefProvider" + } +} + +akka.http { + server { + # The time period within which the TCP binding process must be completed. + # Set to `infinite` to disable. + bind-timeout = 5s + + # Show verbose error messages back to the client + verbose-error-messages = on + } + + parsing { + # Spray doesn't like the AWS4 headers + illegal-header-warnings = on + } +} + +vinyldns { + sqs { + access-key = "x" + secret-key = "x" + signing-region = "x" + service-endpoint = "http://localhost:9324/" + queue-url = "http://localhost:9324/queue/vinyldns-zones" // this is in the docker/elasticmq/custom.conf file + } + + sync-delay = 10000 # 10 second delay for resyncing zone + + db { + local-mode = true # indicates that we should run migrations as we are running in memory + } + + batch-change-limit = 20 # Max change limit per batch request + + # this key is used in order to encrypt/decrypt DNS TSIG keys. We use this dummy one for test purposes, this + # should be overridden with a real value that is hidden for production deployment + crypto { + type = "vinyldns.core.crypto.NoOpCrypto" + } + + monitoring { + logging-interval = 3600s + } + + # log prometheus metrics to logger factory + metrics { + log-to-console = false + } +} diff --git a/modules/api/src/main/resources/db-migrations.conf b/modules/api/src/main/resources/db-migrations.conf new file mode 100644 index 0000000000..7e59145708 --- /dev/null +++ b/modules/api/src/main/resources/db-migrations.conf @@ -0,0 +1,119 @@ +################################################################################################################ +# The configuration used when running migrations +# To use this config, specify -Dconfig.resource=db-migrations.conf +################################################################################################################ +akka { + loglevel = "INFO" + log-dead-letters-during-shutdown = off + log-dead-letters = 0 + + actor { + provider = "akka.actor.LocalActorRefProvider" + } + + persistence { + journal.plugin = "inmemory-journal" + snapshot-store.plugin = "inmemory-snapshot-store" + } +} + +vinyldns { + rest { + host = "localhost" + port = 9002 + } + + db { + local-mode = false + default { + driver = "org.mariadb.jdbc.Driver" + + # requires these as environment variables, will fail if not present + migrationUrl = ${JDBC_MIGRATION_URL} + user = ${JDBC_USER} + password = ${JDBC_PASSWORD} + + poolInitialSize = 10 + poolMaxSize = 20 + connectionTimeoutMillis=5000 + maxLifeTime = 600000 + } + } + + dynamo { + tablePrefix = ${DYNAMO_TABLE_PREFIX} + key = ${DYNAMO_KEY} + secret = ${DYNAMO_SECRET} + endpoint = "https://dynamodb.us-east-1.amazonaws.com" + endpoint = ${?DYNAMO_ENDPOINT} + } + + zoneChanges { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"zoneChange" + provisionedReads=100 + provisionedWrites=100 + } + } + recordSet { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"recordSet" + provisionedReads=100 + provisionedWrites=100 + } + } + + recordChange { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"recordChange" + provisionedReads=100 + provisionedWrites=100 + } + } + + users { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"users" + provisionedReads=100 + provisionedWrites=100 + } + } + + groups { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"groups" + provisionedReads=100 + provisionedWrites=100 + } + } + + membership { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"membership" + provisionedReads=100 + provisionedWrites=100 + } + } + + groupChanges { + dummy = false + + dynamo { + tableName = ${vinyldns.dynamo.tablePrefix}"groupChanges" + provisionedReads=100 + provisionedWrites=100 + } + } +} diff --git a/modules/api/src/main/resources/db/migration/V1__Zones.sql b/modules/api/src/main/resources/db/migration/V1__Zones.sql new file mode 100644 index 0000000000..25b87e2fdf --- /dev/null +++ b/modules/api/src/main/resources/db/migration/V1__Zones.sql @@ -0,0 +1,33 @@ +CREATE SCHEMA IF NOT EXISTS ${dbName}; + +USE ${dbName}; + +/* +Create the Zone table We are not storing the shared flag or the account here as the new Zone repo +is not planned on being backward compatible, and we would have data in the table that we do not need +*/ +CREATE TABLE zone ( + id CHAR(36) NOT NULL, + name VARCHAR(256) NOT NULL, + admin_group_id CHAR(36) NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (id), + INDEX zone_name_index (name), + INDEX zone_admin_group_id_index (admin_group_id) +); + +/* +The Zone Access table provides a lookup to easily find zones that an individual user has access to. +The accessor_id is either a group id OR a user id +The zone_id is the zone_id for the zone +*/ +CREATE TABLE zone_access ( + accessor_id CHAR(36) NOT NULL, + zone_id CHAR(36) NOT NULL, + PRIMARY KEY (accessor_id, zone_id), + CONSTRAINT fk_zone_access FOREIGN KEY (zone_id) + REFERENCES zone(id) + ON DELETE CASCADE, + INDEX user_id_index (accessor_id), + INDEX zone_id_index (zone_id) +); diff --git a/modules/api/src/main/resources/db/migration/V2__BatchChanges.sql b/modules/api/src/main/resources/db/migration/V2__BatchChanges.sql new file mode 100644 index 0000000000..7823eda170 --- /dev/null +++ b/modules/api/src/main/resources/db/migration/V2__BatchChanges.sql @@ -0,0 +1,44 @@ +CREATE SCHEMA IF NOT EXISTS ${dbName}; + +USE ${dbName}; + +/* +Create the batch_change table. This table stores the metadata of a batch change. +It supports easy query by batch change ID, user_id, and combination of user_id & created_time. +*/ +CREATE TABLE batch_change ( + id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + user_name VARCHAR(45) NOT NULL, + created_time DATETIME NOT NULL, + comments VARCHAR(1024) NULL, + PRIMARY KEY (id), + INDEX batch_change_user_id_index (user_id ASC), + INDEX batch_change_user_id_created_time_index (user_id ASC, created_time ASC)); + +/* +Create the single_change table. This table stores the single changes and associated them with batch change via foreign key. +It stores single change data as encoded protobuf in the data BLOLB. Whenever any column in the table is updated, the data column must be updated too. +Just reading from the data column and decode the protobuf format can get all the data for a single change. +It also stores other IDs to associate with zone, record set and record set change. These IDs allow getting additional data from the dynamodb where they're stored. +*/ +CREATE TABLE single_change ( + id CHAR(36) NOT NULL, + seq_num SMALLINT NOT NULL, + input_name VARCHAR(45) NOT NULL, + change_type VARCHAR(20) NOT NULL, + data BLOB NOT NULL, + status VARCHAR(10) NOT NULL, + batch_change_id CHAR(36) NOT NULL, + record_set_change_id CHAR(36) NULL, + record_set_id CHAR(36) NULL, + zone_id CHAR(36) NOT NULL, + PRIMARY KEY (id), + INDEX batch_change_id_index (batch_change_id ASC), + INDEX record_set_change_id_index (record_set_change_id ASC), + CONSTRAINT fk_single_change_batch_change1 + FOREIGN KEY (batch_change_id) + REFERENCES ${dbName}.batch_change (id) + ON DELETE CASCADE); + + diff --git a/modules/api/src/main/resources/logback.xml b/modules/api/src/main/resources/logback.xml new file mode 100644 index 0000000000..a28642adc2 --- /dev/null +++ b/modules/api/src/main/resources/logback.xml @@ -0,0 +1,24 @@ + + + + + %d [test] %-5p | \(%logger{4}:%line\) | %msg %n + + + + + + + + + + + + + + + + + + + diff --git a/modules/api/src/main/resources/reference.conf b/modules/api/src/main/resources/reference.conf new file mode 100644 index 0000000000..9bbb790e30 --- /dev/null +++ b/modules/api/src/main/resources/reference.conf @@ -0,0 +1,148 @@ +################################################################################################################ +# The default configuration values for Vinyll. All configuration values that we use and process in Vinyl +# MUST have a corresponding value in here in the event that the application is not configured, otherwise +# a ConfigurationMissing exception will be thrown by the typesafe config +################################################################################################################ +vinyldns { + + # if we should start up polling for change requests, set this to false for the inactive cluster + processing-disabled = false + + sqs { + polling-interval = 250millis + } + + # approved name servers that are allowable, default to our internal name servers for test + approved-name-servers = [ + "172.17.42.1.", + "ns1.parent.com." + ] + + # approved admin groups that are allowed to manage ns recordsets + approved-ns-groups = [ + "ok-group", + "ok" + ] + + # color should be green or blue, used in order to do blue/green deployment + color = "green" + + # version of vinyldns + version = "unknown" + + # time users have to wait to resync a zone + sync-delay = 600000 + + # we log our endpoint statistics to SLF4J on a period. This allows us to monitor the stats in SPLUNK + # this should be set to a reasonable duration; by default it is 60 seconds; we may want this to be very + # long in a test environment so we do not see stats at all + monitoring { + logging-interval = 60s + } + + # the host and port that the vinyldns service binds to + rest { + host = "127.0.0.1" + port = 9000 + } + + # JDBC Settings, these are all values in scalikejdbc-config, not our own + # these must be overridden to use MYSQL for production use + # assumes a docker or mysql instance running locally + db { + name = "vinyldns" + local-mode = false + default { + driver = "org.mariadb.jdbc.Driver" + migrationUrl = "jdbc:mariadb://localhost:3306/?user=root&password=pass" + url = "jdbc:mariadb://localhost:3306/vinyldns?user=root&password=pass" + user = "root" + password = "pass" + poolInitialSize = 10 + poolMaxSize = 20 + connectionTimeoutMillis = 1000 + maxLifeTime = 600000 + } + } + + dynamo { + key = "vinyldnsTest" + secret = "notNeededForDynamoDbLocal" + endpoint = "http://127.0.0.1:19000" + region = "us-east-1" # note: we are always in us-east-1, but this can be overridden + } + + zoneChanges { + dynamo { + tableName = "zoneChanges" + provisionedReads=30 + provisionedWrites=30 + } + } + recordSet { + dynamo { + tableName = "recordSet" + provisionedReads=30 + provisionedWrites=30 + } + } + recordChange { + dynamo { + tableName = "recordChange" + provisionedReads=30 + provisionedWrites=30 + } + } + users { + dynamo { + tableName = "users" + provisionedReads=30 + provisionedWrites=30 + } + } + groups { + dynamo { + tableName = "groups" + provisionedReads=30 + provisionedWrites=30 + } + } + groupChanges { + dynamo { + tableName = "groupChanges" + provisionedReads=30 + provisionedWrites=30 + } + } + membership { + dynamo { + tableName = "membership" + provisionedReads=30 + provisionedWrites=30 + } + } + + defaultZoneConnection { + name = "vinyldns." + keyName = "vinyldns." + key = "nzisn+4G2ldMn0q1CV3vsg==" + primaryServer = "127.0.0.1:19001" + } + + defaultTransferConnection { + name = "vinyldns." + keyName = "vinyldns." + key = "nzisn+4G2ldMn0q1CV3vsg==" + primaryServer = "127.0.0.1:19001" + } + + batch-change-limit = 20 + + # whether user secrets are expected to be encrypted or not + encrypt-user-secrets = false + + # log prometheus metrics to logger factory + metrics { + log-to-console = true + } +} diff --git a/modules/api/src/main/resources/test/logback.xml b/modules/api/src/main/resources/test/logback.xml new file mode 100644 index 0000000000..a28642adc2 --- /dev/null +++ b/modules/api/src/main/resources/test/logback.xml @@ -0,0 +1,24 @@ + + + + + %d [test] %-5p | \(%logger{4}:%line\) | %msg %n + + + + + + + + + + + + + + + + + + + diff --git a/modules/api/src/main/resources/vinyldns-ascii.txt b/modules/api/src/main/resources/vinyldns-ascii.txt new file mode 100644 index 0000000000..bb10a843af --- /dev/null +++ b/modules/api/src/main/resources/vinyldns-ascii.txt @@ -0,0 +1,6 @@ + .__ .__ .___ +___ _|__| ____ ___.__.| | __| _/____ ______ +\ \/ / |/ < | || | / __ |/ \ / ___/ + \ /| | | \___ || |__/ /_/ | | \\___ \ + \_/ |__|___| / ____||____/\____ |___| /____ > + \/\/ \/ \/ \/ diff --git a/modules/api/src/main/scala/db/migration/MigrationRunner.scala b/modules/api/src/main/scala/db/migration/MigrationRunner.scala new file mode 100644 index 0000000000..734b3cb43b --- /dev/null +++ b/modules/api/src/main/scala/db/migration/MigrationRunner.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package db.migration + +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.FlywayException +import org.slf4j.LoggerFactory +import vinyldns.api.repository.mysql.VinylDNSJDBC +import scala.collection.JavaConverters._ + +object MigrationRunner { + + private val logger = LoggerFactory.getLogger("MigrationRunner") + + def main(args: Array[String]): Unit = { + + logger.info("Running migrations...") + + val migration = new Flyway() + val dbName = VinylDNSJDBC.config.getString("name") + + // Must use the classpath to pull in both scala and sql migrations + migration.setLocations("classpath:db/migration") + migration.setDataSource(VinylDNSJDBC.instance.migrationDataSource) + migration.setSchemas(dbName) + val placeholders = Map("dbName" -> dbName) + migration.setPlaceholders(placeholders.asJava) + + // Runs ALL flyway migrations including SQL and scala + try { + migration.migrate() + logger.info("migrations complete") + System.exit(0) + } catch { + case fe: FlywayException => + logger.error("migrations failed!", fe) + + // Repair will fix meta data issues (if any) in the flyway database table. Recommended when + // a catastrophic failure occurs + migration.repair() + System.exit(1) + } + } +} diff --git a/modules/api/src/main/scala/vinyldns/api/Boot.scala b/modules/api/src/main/scala/vinyldns/api/Boot.scala new file mode 100644 index 0000000000..e58fc78e49 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/Boot.scala @@ -0,0 +1,189 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.stream.{ActorMaterializer, Materializer} +import cats.effect.IO +import io.prometheus.client.CollectorRegistry +import io.prometheus.client.dropwizard.DropwizardExports +import io.prometheus.client.hotspot.DefaultExports +import org.slf4j.LoggerFactory +import vinyldns.api.domain.AccessValidations +import vinyldns.api.domain.batch.{ + BatchChangeConverter, + BatchChangeRepository, + BatchChangeService, + BatchChangeValidations +} +import vinyldns.api.domain.membership._ +import vinyldns.api.domain.record.{RecordChangeRepository, RecordSetRepository, RecordSetService} +import vinyldns.api.domain.zone._ +import vinyldns.api.engine.ProductionZoneCommandHandler +import vinyldns.api.engine.sqs.{SqsCommandBus, SqsConnection} +import vinyldns.api.repository.mysql.VinylDNSJDBC +import vinyldns.api.route.{HealthService, VinylDNSService} +import vinyldns.core.crypto.Crypto + +import scala.collection.JavaConverters._ +import scala.concurrent.{ExecutionContext, Future} +import scala.io.{Codec, Source} + +object Boot extends App { + + private val logger = LoggerFactory.getLogger("Boot") + private implicit val system: ActorSystem = VinylDNSConfig.system + private implicit val materializer: Materializer = ActorMaterializer() + private implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.global + + def vinyldnsBanner(): IO[String] = IO { + val stream = getClass.getResourceAsStream("/vinyldns-ascii.txt") + val vinyldnsBannerText = "\n" + Source.fromInputStream(stream)(Codec.UTF8).mkString + "\n" + stream.close() + vinyldnsBannerText + } + + /* Boot straps the entire application, if anything fails, we all fail! */ + def runApp(): IO[Future[Http.ServerBinding]] = { + def getNSApprovedGroupIds( + allGroups: Future[Set[Group]], + approved: List[String]): IO[Set[String]] = { + val ids = allGroups.map { + _.collect { + case grp if approved.contains(grp.name) => grp.id + } + } + IO.fromFuture(IO(ids)) + } + + // Use an effect type to lift anything that can fail into the effect type. This ensures + // that if anything fails, the app does not start! + for { + banner <- vinyldnsBanner() + _ <- Crypto.loadCrypto(VinylDNSConfig.cryptoConfig) // load crypto + _ <- IO(VinylDNSJDBC.instance) // initializes our JDBC repositories + userRepo <- IO(UserRepository()) + groupRepo <- IO(GroupRepository()) + membershipRepo <- IO(MembershipRepository()) + zoneRepo <- IO(ZoneRepository()) + groupChangeRepo <- IO(GroupChangeRepository()) + recordSetRepo <- IO(RecordSetRepository()) + recordChangeRepo <- IO(RecordChangeRepository()) + zoneChangeRepo <- IO(ZoneChangeRepository()) + batchChangeRepo <- IO(BatchChangeRepository()) + sqsConfig <- IO(VinylDNSConfig.sqsConfig) + sqsConnection <- IO(SqsConnection(sqsConfig)) + processingDisabled <- IO(VinylDNSConfig.vinyldnsConfig.getBoolean("processing-disabled")) + processingSignal <- fs2.async.signalOf[IO, Boolean](processingDisabled) + restHost <- IO(VinylDNSConfig.restConfig.getString("host")) + restPort <- IO(VinylDNSConfig.restConfig.getInt("port")) + approvedNsGroupNames <- IO( + VinylDNSConfig.vinyldnsConfig.getStringList("approved-ns-groups").asScala.toList) + approvedNsGroupIds <- getNSApprovedGroupIds(groupRepo.getAllGroups(), approvedNsGroupNames) + batchChangeLimit <- IO(VinylDNSConfig.vinyldnsConfig.getInt("batch-change-limit")) + syncDelay <- IO(VinylDNSConfig.vinyldnsConfig.getInt("sync-delay")) + _ <- fs2.async.start( + ProductionZoneCommandHandler.run( + sqsConnection, + processingSignal, + zoneRepo, + zoneChangeRepo, + recordChangeRepo, + recordSetRepo, + batchChangeRepo, + sqsConfig)) + } yield { + val zoneValidations = new ZoneValidations(syncDelay) + val accessValidations = new AccessValidations(approvedNsGroupIds) + val batchChangeValidations = new BatchChangeValidations(batchChangeLimit, accessValidations) + val commandBus = new SqsCommandBus(sqsConnection) + val membershipService = + new MembershipService(groupRepo, userRepo, membershipRepo, zoneRepo, groupChangeRepo) + val connectionValidator = + new ZoneConnectionValidator(VinylDNSConfig.defaultZoneConnection, system.scheduler) + val recordSetService = new RecordSetService( + zoneRepo, + recordSetRepo, + recordChangeRepo, + userRepo, + commandBus, + accessValidations) + val zoneService = new ZoneService( + zoneRepo, + groupRepo, + userRepo, + zoneChangeRepo, + connectionValidator, + commandBus, + zoneValidations, + accessValidations) + val healthService = new HealthService(zoneRepo) + val batchChangeConverter = new BatchChangeConverter(batchChangeRepo, commandBus) + val batchChangeService = new BatchChangeService( + zoneRepo, + recordSetRepo, + batchChangeValidations, + batchChangeRepo, + batchChangeConverter) + val collectorRegistry = CollectorRegistry.defaultRegistry + val vinyldnsService = new VinylDNSService( + membershipService, + processingSignal, + zoneService, + healthService, + recordSetService, + batchChangeService, + collectorRegistry) + + DefaultExports.initialize() + collectorRegistry.register(new DropwizardExports(VinylDNSMetrics.metricsRegistry)) + + // Need to register a jvm shut down hook to make sure everything is cleaned up, especially important for + // running locally. + sys.ShutdownHookThread { + logger.error("STOPPING VINYLDNS SERVER...") + + // shutdown sqs gracefully + sqsConnection.shutdown() + + // exit JVM when ActorSystem has been terminated + system.registerOnTermination(System.exit(0)) + + // shut down ActorSystem + system.terminate() + + () + } + + logger.error(s"STARTING VINYLDNS SERVER ON $restHost:$restPort") + logger.error(banner) + + // Starts up our http server + Http().bindAndHandle(vinyldnsService.routes, restHost, restPort) + } + } + + // runApp gives us a Task, we actually have to run it! Running it will yield a Future, which is our app! + runApp().unsafeRunAsync { + case Right(_) => + logger.error("VINYLDNS SERVER STARTED SUCCESSFULLY!!") + case Left(startupFailure) => + logger.error(s"VINYLDNS SERVER UNABLE TO START $startupFailure") + startupFailure.printStackTrace() + } +} diff --git a/modules/api/src/main/scala/vinyldns/api/Instrumented.scala b/modules/api/src/main/scala/vinyldns/api/Instrumented.scala new file mode 100644 index 0000000000..709b131e09 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/Instrumented.scala @@ -0,0 +1,56 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api + +import java.util.concurrent.TimeUnit + +import com.codahale.metrics.{JmxReporter, MetricRegistry, Slf4jReporter} +import nl.grons.metrics.scala.InstrumentedBuilder +import org.slf4j.LoggerFactory + +object VinylDNSMetrics { + + val metricsRegistry: MetricRegistry = new MetricRegistry + + // Output all VinylDNS metrics as jmx under the "vinyldns.api" domain as milliseconds + JmxReporter + .forRegistry(metricsRegistry) + .inDomain("vinyldns.api") + .build() + .start() + + val logReporter: Slf4jReporter = + Slf4jReporter + .forRegistry(metricsRegistry) + .outputTo(LoggerFactory.getLogger("vinyldns.api.metrics")) + .build() + + val logMetrics = VinylDNSConfig.vinyldnsConfig.getBoolean("metrics.log-to-console") + if (logMetrics) { + // Record metrics once per minute + logReporter.start(1, TimeUnit.MINUTES) + } +} + +/** + * Guidance from the scala-metrics library we are using, this is to be included in classes to help out with + * metric recording + */ +trait Instrumented extends InstrumentedBuilder { + + val metricRegistry: MetricRegistry = VinylDNSMetrics.metricsRegistry +} diff --git a/modules/api/src/main/scala/vinyldns/api/Interfaces.scala b/modules/api/src/main/scala/vinyldns/api/Interfaces.scala new file mode 100644 index 0000000000..5c14488684 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/Interfaces.scala @@ -0,0 +1,124 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api + +import akka.actor.Scheduler +import akka.pattern.after + +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.{ExecutionContext, Future} +import scalaz._ +import scalaz.syntax.ToEitherOps + +object Interfaces extends ToEitherOps { + + /** + * the type returned from the ZoneActor is a ScalaZ disjunction \/, EitherT extends that to support + * Future[ZoneError \/ ZoneEvt] this makes it easy to use results in for comprehensions among other things + */ + type Result[A] = EitherT[Future, Throwable, A] + + /* Transforms a disjunction to a Result */ + def result[A](either: => Throwable \/ A): Result[A] = Result(Future.successful(either)) + + /* Transforms any value at all into a positive result */ + def result[A](a: A): Result[A] = Result(Future.successful(a.right)) + + /* Transforms an error into a Result with a left disjunction */ + def result[A](error: Throwable): Result[A] = Result(Future.successful(\/.left(error))) + + def ensuring(onError: => Throwable)(check: => Boolean): Disjunction[Throwable, Unit] = + if (check) ().right else onError.left + + /** + * If the future is a disjunction already, return the disjunction; otherwise return the successful value + * as a disjunction + */ + def result[A](fut: Future[_])(implicit ec: ExecutionContext): Result[A] = + Result( + fut + .map { + case disj: Disjunction[_, _] => disj + case e: Throwable => e.left + case a => a.right + } + .recover { + case e: Throwable => e.left + } + .mapTo[Throwable \/ A] + ) + + def withTimeout[A]( + theFuture: => Future[A], + duration: FiniteDuration, + error: Throwable, + scheduler: Scheduler)(implicit ec: ExecutionContext): Result[A] = result[A] { + val timeOut = after(duration = duration, using = scheduler)(Future.failed(error)) + + Future.firstCompletedOf(Seq(theFuture, timeOut)) + } + + /* Pimps futures to easily lift the future to a Result */ + implicit class FutureResultImprovements(fut: Future[_])(implicit ec: ExecutionContext) { + + /* Lifts a future into a Result */ + def toResult[A]: Result[A] = result[A](fut) + } + + /*Convenience operations for working with Future of Option*/ + implicit class FutureOptionImprovements[A](fut: Future[Option[A]])( + implicit ec: ExecutionContext) { + + /* If the result of the future is None, then fail with the provided parameter `ifNone` */ + def orFail(ifNone: => Throwable): Future[Throwable \/ A] = fut.map { + case Some(a) => a.right + case None => ifNone.left + } + } + + /* Pimps any value to easily lift the class to a Result */ + implicit class AnyResultImprovements[A](a: A)(implicit ec: ExecutionContext) { + def toResult: Result[A] = result[A](a) + } + + /* Pimps any existing Disjunction to easily lift the class to a Result */ + implicit class DisjunctionImprovements[A](disj: Throwable \/ A)(implicit ec: ExecutionContext) { + def toResult: Result[A] = result[A](disj) + } + + implicit class BooleanImprovements(bool: Boolean)(implicit ec: ExecutionContext) { + /* If false, then fail with the provided parameter `ifFalse` */ + def failWith(ifFalse: Throwable): Result[Unit] = + if (bool) result(()) + else result[Unit](ifFalse) + } + + implicit class OptionImprovements[A](opt: Option[A])(implicit ec: ExecutionContext) { + + /* If the result of the future is None, then fail with the provided parameter `ifNone` */ + def orFail(ifNone: Throwable): Result[A] = opt match { + case Some(a) => result(a) + case None => result[A](ifNone) + } + } + +} + +object Result { + + def apply[A](f: => Future[Throwable \/ A]): Interfaces.Result[A] = EitherT(f) +} diff --git a/modules/api/src/main/scala/vinyldns/api/VinylDNSConfig.scala b/modules/api/src/main/scala/vinyldns/api/VinylDNSConfig.scala new file mode 100644 index 0000000000..41b17f808f --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/VinylDNSConfig.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api + +import akka.actor.ActorSystem +import com.typesafe.config.{Config, ConfigFactory} +import vinyldns.api.domain.zone.ZoneConnection + +object VinylDNSConfig { + + lazy val config: Config = ConfigFactory.load() + lazy val vinyldnsConfig: Config = config.getConfig("vinyldns") + lazy val dynamoConfig: Config = vinyldnsConfig.getConfig("dynamo") + lazy val restConfig: Config = vinyldnsConfig.getConfig("rest") + lazy val monitoringConfig: Config = vinyldnsConfig.getConfig("monitoring") + lazy val accountStoreConfig: Config = vinyldnsConfig.getConfig("accounts") + lazy val zoneChangeStoreConfig: Config = vinyldnsConfig.getConfig("zoneChanges") + lazy val recordSetStoreConfig: Config = vinyldnsConfig.getConfig("recordSet") + lazy val recordChangeStoreConfig: Config = vinyldnsConfig.getConfig("recordChange") + lazy val usersStoreConfig: Config = vinyldnsConfig.getConfig("users") + lazy val groupsStoreConfig: Config = vinyldnsConfig.getConfig("groups") + lazy val groupChangesStoreConfig: Config = vinyldnsConfig.getConfig("groupChanges") + lazy val membershipStoreConfig: Config = vinyldnsConfig.getConfig("membership") + lazy val dbConfig: Config = vinyldnsConfig.getConfig("db") + lazy val sqsConfig: Config = vinyldnsConfig.getConfig("sqs") + lazy val cryptoConfig: Config = vinyldnsConfig.getConfig("crypto") + lazy val system: ActorSystem = ActorSystem("VinylDNS", VinylDNSConfig.config) + lazy val encryptUserSecrets: Boolean = vinyldnsConfig.getBoolean("encrypt-user-secrets") + + lazy val defaultZoneConnection: ZoneConnection = { + val connectionConfig = VinylDNSConfig.vinyldnsConfig.getConfig("defaultZoneConnection") + val name = connectionConfig.getString("name") + val keyName = connectionConfig.getString("keyName") + val key = connectionConfig.getString("key") + val primaryServer = connectionConfig.getString("primaryServer") + ZoneConnection(name, keyName, key, primaryServer).encrypted() + } + + lazy val defaultTransferConnection: ZoneConnection = { + val connectionConfig = VinylDNSConfig.vinyldnsConfig.getConfig("defaultTransferConnection") + val name = connectionConfig.getString("name") + val keyName = connectionConfig.getString("keyName") + val key = connectionConfig.getString("key") + val primaryServer = connectionConfig.getString("primaryServer") + ZoneConnection(name, keyName, key, primaryServer).encrypted() + } +} diff --git a/modules/api/src/main/scala/vinyldns/api/domain/AccessValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/AccessValidations.scala new file mode 100644 index 0000000000..a6b3cfc9a0 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/domain/AccessValidations.scala @@ -0,0 +1,205 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain + +import scalaz.Disjunction +import vinyldns.api.Interfaces.ensuring +import vinyldns.api.domain.auth.AuthPrincipal +import vinyldns.api.domain.record.{RecordSet, RecordType} +import vinyldns.api.domain.record.RecordType.RecordType +import vinyldns.api.domain.zone.AccessLevel.AccessLevel +import vinyldns.api.domain.zone._ + +class AccessValidations(approvedNsGroups: Set[String] = Set()) extends AccessValidationAlgebra { + + def canSeeZone(auth: AuthPrincipal, zone: Zone): Disjunction[Throwable, Unit] = + ensuring( + NotAuthorizedError(s"User ${auth.signedInUser.userName} cannot access zone '${zone.name}'"))( + (hasZoneAdminAccess(auth, zone) || zone.shared) || userHasAclRules(auth, zone)) + + def canChangeZone(auth: AuthPrincipal, zone: Zone): Disjunction[Throwable, Unit] = + ensuring( + NotAuthorizedError(s"User ${auth.signedInUser.userName} cannot modify zone '${zone.name}'"))( + hasZoneAdminAccess(auth, zone)) + + def canAddRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] = { + val accessLevel = getAccessLevel(auth, recordName, recordType, zone) + val access = ensuring( + NotAuthorizedError(s"User ${auth.signedInUser.userName} does not have access to create " + + s"$recordName.${zone.name}"))( + accessLevel == AccessLevel.Delete || accessLevel == AccessLevel.Write) + + for { + _ <- access + _ <- doNSCheck(auth, recordType, zone) + } yield ().right + } + + def canUpdateRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] = { + val accessLevel = getAccessLevel(auth, recordName, recordType, zone) + val access = ensuring( + NotAuthorizedError(s"User ${auth.signedInUser.userName} does not have access to update " + + s"$recordName.${zone.name}"))( + accessLevel == AccessLevel.Delete || accessLevel == AccessLevel.Write) + + for { + _ <- access + _ <- doNSCheck(auth, recordType, zone) + } yield ().right + } + + def canDeleteRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] = { + val access = ensuring( + NotAuthorizedError(s"User ${auth.signedInUser.userName} does not have access to delete " + + s"$recordName.${zone.name}"))( + getAccessLevel(auth, recordName, recordType, zone) == AccessLevel.Delete) + + for { + _ <- access + _ <- doNSCheck(auth, recordType, zone) + } yield ().right + } + + def canViewRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] = + ensuring( + NotAuthorizedError(s"User ${auth.signedInUser.userName} does not have access to view " + + s"$recordName.${zone.name}"))( + getAccessLevel(auth, recordName, recordType, zone) != AccessLevel.NoAccess) + + def getListAccessLevels( + auth: AuthPrincipal, + recordSets: List[RecordSet], + zone: Zone): List[RecordSetInfo] = + if (hasZoneAdminAccess(auth, zone)) recordSets.map(RecordSetInfo(_, AccessLevel.Delete)) + else { + val rulesForUser = zone.acl.rules.filter(ruleAppliesToUser(auth, _)) + + def getAccessFromUserAcls(recordName: String, recordType: RecordType): AccessLevel = { + // user filter has already been applied + val validRules = rulesForUser.filter { rule => + ruleAppliesToRecordType(recordType, rule) && ruleAppliesToRecordName( + recordName, + recordType, + zone, + rule) + } + getPrioritizedAccessLevel(recordType, validRules) + } + + recordSets.map { rs => + val accessLevel = getAccessFromUserAcls(rs.name, rs.typ) + RecordSetInfo(rs, accessLevel) + } + } + + /* Non-algebra methods */ + def doNSCheck( + authPrincipal: AuthPrincipal, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] = { + def nsAuthorized: Boolean = + authPrincipal.signedInUser.isSuper || + (authPrincipal.isAuthorized(zone.adminGroupId) && approvedNsGroups.contains( + zone.adminGroupId)) + + ensuring( + NotAuthorizedError( + "Do not have permissions to manage NS recordsets, please contact vinyldns-support"))( + recordType != RecordType.NS || (recordType == RecordType.NS && nsAuthorized)) + } + + def hasZoneAdminAccess(auth: AuthPrincipal, zone: Zone): Boolean = + auth.isAuthorized(zone.adminGroupId) + + def getAccessFromAcl( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): AccessLevel = { + val validRules = zone.acl.rules.filter { rule => + ruleAppliesToUser(auth, rule) && ruleAppliesToRecordType(recordType, rule) && ruleAppliesToRecordName( + recordName, + recordType, + zone, + rule) + } + getPrioritizedAccessLevel(recordType, validRules) + } + + def userHasAclRules(auth: AuthPrincipal, zone: Zone): Boolean = + zone.acl.rules.exists(ruleAppliesToUser(auth, _)) + + // Pull ACL rules that are relevant for the user based on userId, groups + def ruleAppliesToUser(auth: AuthPrincipal, rule: ACLRule): Boolean = + (rule.userId, rule.groupId) match { + case (None, None) => true + case (Some(userId), _) if userId == auth.userId => true + case (_, Some(groupId)) if auth.memberGroupIds.contains(groupId) => true + case _ => false + } + + // Pull ACL rules that are relevant for the user based on record mask + def ruleAppliesToRecordName( + recordName: String, + recordType: RecordType, + zone: Zone, + rule: ACLRule): Boolean = + rule.recordMask match { + case Some(mask) if recordType == RecordType.PTR => + ReverseZoneHelpers.recordsetIsWithinCidrMask(mask, zone, recordName) + case Some(mask) => recordName.matches(mask) + case None => true + } + + // Pull ACL rules that are relevant for the record based on type + def ruleAppliesToRecordType(recordType: RecordType, rule: ACLRule): Boolean = + rule.recordTypes.isEmpty || rule.recordTypes.contains(recordType) + + def getPrioritizedAccessLevel(recordType: RecordType, rules: Set[ACLRule]): AccessLevel = + if (rules.isEmpty) { + AccessLevel.NoAccess + } else { + implicit val ruleOrder: ACLRuleOrdering = + if (recordType == RecordType.PTR) PTRACLRuleOrdering else ACLRuleOrdering + val topRule = rules.toSeq.min + topRule.accessLevel + } + + def getAccessLevel( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): AccessLevel = + if (hasZoneAdminAccess(auth, zone)) AccessLevel.Delete + else getAccessFromAcl(auth, recordName, recordType, zone) +} diff --git a/modules/api/src/main/scala/vinyldns/api/domain/AccessValidationsAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/AccessValidationsAlgebra.scala new file mode 100644 index 0000000000..f841770452 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/domain/AccessValidationsAlgebra.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain + +import scalaz.Disjunction +import scalaz.syntax.ToEitherOps +import vinyldns.api.domain.auth.AuthPrincipal +import vinyldns.api.domain.record.RecordSet +import vinyldns.api.domain.record.RecordType.RecordType +import vinyldns.api.domain.zone.{RecordSetInfo, Zone} + +trait AccessValidationAlgebra extends ToEitherOps { + + def canSeeZone(auth: AuthPrincipal, zone: Zone): Disjunction[Throwable, Unit] + + def canChangeZone(auth: AuthPrincipal, zone: Zone): Disjunction[Throwable, Unit] + + def canAddRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] + + def canUpdateRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] + + def canDeleteRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] + + def canViewRecordSet( + auth: AuthPrincipal, + recordName: String, + recordType: RecordType, + zone: Zone): Disjunction[Throwable, Unit] + + def getListAccessLevels( + auth: AuthPrincipal, + recordSets: List[RecordSet], + zone: Zone): List[RecordSetInfo] +} diff --git a/modules/api/src/main/scala/vinyldns/api/domain/DomainValidationErrors.scala b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidationErrors.scala new file mode 100644 index 0000000000..1d312d5ddc --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidationErrors.scala @@ -0,0 +1,124 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain + +import vinyldns.api.domain.batch.SupportedBatchChangeRecordTypes +import vinyldns.api.domain.record.RecordType +import vinyldns.api.domain.record.RecordType.RecordType + +// $COVERAGE-OFF$ +sealed trait DomainValidationError { + def message: String +} + +final case class InvalidDomainName(param: String) extends DomainValidationError { + def message: String = + s"""Invalid domain name: "$param", valid domain names must be letters, numbers, and hyphens, """ + + "joined by dots, and terminated with a dot." +} + +final case class InvalidLength(param: String, minLengthInclusive: Int, maxLengthInclusive: Int) + extends DomainValidationError { + def message: String = + s"""Invalid length: "$param", length needs to be between $minLengthInclusive and $maxLengthInclusive characters.""" +} + +final case class InvalidEmail(param: String) extends DomainValidationError { + def message: String = s"""Invalid email address: "$param".""" +} + +final case class InvalidRecordType(param: String) extends DomainValidationError { + def message: String = + s"""Invalid record type: "$param", valid record types include ${RecordType.values}.""" +} + +final case class InvalidPortNumber(param: String, minPort: Int, maxPort: Int) + extends DomainValidationError { + def message: String = + s"""Invalid port number: "$param", port must be a number between $minPort and $maxPort.""" +} + +final case class InvalidIpv4Address(param: String) extends DomainValidationError { + def message: String = s"""Invalid IPv4 address: "$param".""" +} + +final case class InvalidIpv6Address(param: String) extends DomainValidationError { + def message: String = s"""Invalid IPv6 address: "$param".""" +} + +final case class InvalidIPAddress(param: String) extends DomainValidationError { + def message: String = s"""Invalid IP address: "$param".""" +} + +final case class InvalidTTL(param: Long) extends DomainValidationError { + def message: String = + s"""Invalid TTL: "${param.toString}", must be a number between """ + + s"${DomainValidations.TTL_MIN_LENGTH} and ${DomainValidations.TTL_MAX_LENGTH}." +} + +final case class InvalidMxPreference(param: Long) extends DomainValidationError { + def message: String = + s"""Invalid MX Preference: "${param.toString}", must be a number between """ + + s"${DomainValidations.MX_PREFERENCE_MIN_VALUE} and ${DomainValidations.MX_PREFERENCE_MAX_VALUE}." +} + +final case class InvalidBatchRecordType(param: String) extends DomainValidationError { + def message: String = + s"""Invalid Batch Record Type: "$param", valid record types for batch changes include """ + + s"${SupportedBatchChangeRecordTypes.get}." +} + +final case class ZoneDiscoveryError(name: String) extends DomainValidationError { + def message: String = + s"""Zone Discovery Failed: zone for "$name" does not exist in VinylDNS. """ + + "If zone exists, then it must be created in VinylDNS." +} + +final case class RecordAlreadyExists(name: String) extends DomainValidationError { + def message: String = + s"""Record "$name" Already Exists: cannot add an existing record; to update it, """ + + "issue a DeleteRecordSet then an Add." +} + +final case class RecordDoesNotExist(name: String) extends DomainValidationError { + def message: String = + s"""Record "$name" Does Not Exist: cannot delete a record that does not exist.""" +} + +final case class CnameIsNotUniqueError(name: String, typ: RecordType) + extends DomainValidationError { + def message: String = + "CNAME Conflict: CNAME record names must be unique. " + + s"""Existing record with name "$name" and type "$typ" conflicts with this record.""" +} + +final case class UserIsNotAuthorized(user: String) extends DomainValidationError { + def message: String = s"""User "$user" is not authorized.""" +} + +final case class RecordNameNotUniqueInBatch(name: String, typ: RecordType) + extends DomainValidationError { + def message: String = + s"""Record Name "$name" Not Unique In Batch Change: cannot have multiple "$typ" records with the same name.""" +} + +final case class RecordInReverseZoneError(name: String, typ: String) extends DomainValidationError { + def message: String = + "Invalid Record Type In Reverse Zone: record with name " + + s""""$name" and type "$typ" is not allowed in a reverse zone.""" +} +// $COVERAGE-ON$ diff --git a/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala new file mode 100644 index 0000000000..046c920bc8 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/domain/DomainValidations.scala @@ -0,0 +1,155 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.domain + +import scalaz.Scalaz._ +import scalaz._ +import vinyldns.api.domain.ValidationImprovements._ +import vinyldns.api.domain.record.RecordType.{RecordType, _} + +import scala.util.Try +import scala.util.matching.Regex + +/* + Object to house common domain validations + */ +object DomainValidations { + val validEmailRegex: Regex = """^([0-9a-zA-Z_\-\.]+)@([0-9a-zA-Z_\-\.]+)\.([a-zA-Z]{2,5})$""".r + val validFQDNRegex: Regex = + """^(?:([0-9a-zA-Z]{1,63}|[0-9a-zA-Z]{1}[0-9a-zA-Z\-\/]{0,61}[0-9a-zA-Z]{1})\.)*$""".r + val validIpv4Regex: Regex = + """^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$""".r + val validIpv6Regex: Regex = + """^( + #([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}| + #([0-9a-fA-F]{1,4}:){1,7}:| + #([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}| + #([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}| + #([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}| + #([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}| + #([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}| + #[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})| + #:((:[0-9a-fA-F]{1,4}){1,7}|:)| + #fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}| + #::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]| + #(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])| + #([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5] + #|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]) + #)$""".stripMargin('#').replaceAll("\n", "").r + val PORT_MIN_VALUE: Int = 0 + val PORT_MAX_VALUE: Int = 65535 + val HOST_MIN_LENGTH: Int = 2 + val HOST_MAX_LENGTH: Int = 255 + val TTL_MAX_LENGTH: Int = 2147483647 + val TTL_MIN_LENGTH: Int = 30 + val TXT_TEXT_MIN_LENGTH: Int = 1 + val TXT_TEXT_MAX_LENGTH: Int = 64764 + val MX_PREFERENCE_MIN_VALUE: Int = 0 + val MX_PREFERENCE_MAX_VALUE: Int = 65535 + + def validateEmail(email: String): ValidationNel[DomainValidationError, String] = + /* + Basic e-mail checking that also blocks some positive e-mails (by RFC standards) + (eg. e-mails containing hex and special characters.) + */ + if (validEmailRegex.findFirstIn(email).isDefined) email.successNel + else InvalidEmail(email).failureNel + + def validateHostName(name: String): ValidationNel[DomainValidationError, String] = { + /* + Label rules are as follows (from RFC 952; detailed in RFC 1034): + - Starts with a letter, OR digit (as of RFC 1123) + - Interior contains letter, digit or hyphen + - Ends with a letter or digit + All possible labels permutations: + - A single letter/digit: [0-9a-zA-Z]{1} + - A combination of 1-63 letters/digits: [0-9a-zA-Z]{1,63} + - A single letter/digit followed by up to 61 letters, digits, hyphens or slashes + and ending with a letter/digit:[0-9a-zA-Z]{1}[0-9a-zA-Z\-]{0,61}[0-9a-zA-Z]{1} + A valid domain name is a series of one or more +Once you have your zone ready, you can use VinylDNS to connect to it. + +1. Create an Admin Group for your zone. Members of the zone admin group have _full_ access +to all records and permissions in the zone. Typically, this should be a limited set of +users. +2. From the Zones screen, click the *Connect* button. This will show the zone connect +form. +3. Enter the full name of the zone, example "test.sys.example.com" +4. Enter the email distribution list for the zone. This is typically a distribution list +email for the team that owns the zone. +5. Select the Admin Group for the zone. +6. If you do not have any custom TSIG keys, you can leave the connection information _empty_ +7. If you do have custom TSIG keys, read the section below on *Understanding Connections* +8. Click the *Connect* button at the bottom of the form. +9. You _may_ have to click the *Refresh* button from the zone list to see your new zone. +10. If you see error messages, please consult the FAQ. + +#### Managing Records +In the *Manage Records* tab in your zone, you can create, update, and delete +existing records. + +The Record View lists _Record Sets_, which are records that have the same +name but different record data. Not all record types support record sets. + +When you make any change, it will be issued _immediately_ upon confirming +the change to the DNS backend. + +If for any reason the change failed, you can view the change in the "Recent Changes" +at the top of the screen, or look at the "Change History" to see what went wrong. +The "Additional Info" on the change will contain details of the failure. + +#### Understanding Connections +VinylDNS provides the ability to specify 2 different connections to the backend DNS servers. + +- The primary connection is used for issuing DNS updates +- The transfer connection is used for syncing DNS data with VinylDNS + +If you do not have _any_ keys, then you can leave this information empty. VinylDNS will +assume a set of **default** keys that should provide the access VinylDNS needs to manage +your zone. + +If you have an existing TSIG key that you are using for issuing DDNS updates to DNS, +and you wish to continue to use it, you _must_ request that your zone be setup to +allow transfers from VinylDNS. + +If you have an existing TSIG key that you are using for issuing DDNS updates, +and you no longer need the key, please request that the key is _revoked_. Once +your key is revoked, you can leave the connections empty in which case VinylDNS +will assume the default keys and they should work. diff --git a/modules/docs/src/main/tut/apidocs/batchchange-errors.md b/modules/docs/src/main/tut/apidocs/batchchange-errors.md new file mode 100644 index 0000000000..3019805f8e --- /dev/null +++ b/modules/docs/src/main/tut/apidocs/batchchange-errors.md @@ -0,0 +1,435 @@ +--- +layout: docs +title: "Batch Change Errors" +section: "apidocs" +--- + +# Batch Change Errors +1. [By-Change Accumulated Errors](#by-change-accumulated-errors) +2. [Full-Request Errors](#full-request-errors) + +### BY-CHANGE ACCUMULATED ERRORS + +Since all of the batch changes are being validated simultaneously, it is possible to encounter a variety of errors for a given change. Each change that is associated with errors will have its own list of **errors** containing one or more errors; any changes without the **errors** list have been fully validated and are good to submit. If any change in the batch is deemed invalid for any reason, no changes in the batch will be applied. These types of errors will probably account for the majority of errors that users encounter. + +By-change accumulated errors are errors that get collected at different validation stages and correspond to individual change inputs. By-change accumulated errors are grouped into the following stages: + +- Independent input validations: Validate invalid data input formats and values. +- Record and zone discovery: Resolve record and zone from fully-qualified input name. +- Dependent context validations: Check for sufficient user access and conflicts with existing records or other submissions within the batch. + +Since by-change accumulated errors are collected at different stages, errors at later stages may exist but will not appear unless errors at earlier stages are addressed. + +#### EXAMPLE ERROR RESPONSE BY CHANGE + + +``` +[ + { + "changeType": "Add", + "inputName": "good-A.another.example.com.", + "type": "A", + "ttl": 200, + "record": { + "address": "1.2.3.4" + } + }, + { + "changeType": "Add", + "inputName": "duplicate.example.com", + "type": "CNAME", + "ttl": 200, + "record": { + "cname": "test.example.com." + }, + "errors": [ + "Record with name "duplicate.example.com." is not unique in the batch change. CNAME record cannot use duplicate name." + ] + }, + { + "changeType": "Add", + "inputName": "duplicate.example.com", + "type": "A", + "ttl": 300, + "record": { + "address": "1.2.3.4" + } + }, + { + "changeType": "Add", + "inputName": "bad-ttl-and-invalid-name$.sample.com.”, + "type": "A", + "ttl": 29, + "record": { + "address": "1.2.3.4" + }, + "errors": [ + "Failed validation 29, TTL must be between 30 and 2147483647.", + "Failed validation bad-ttl-and-invalid-name$.sample.com., valid domain names are a series of one or more labels joined by dots and terminate on a dot." + ] + } +] +``` + +#### By-Change Errors + +1. [Invalid Domain Name](#InvalidDomainName) +2. [Invalid Length](#InvalidLength) +3. [Invalid Record Type](#InvalidRecordType) +4. [Invalid IPv4 Address](#InvalidIpv4Address) +5. [Invalid IPv6 Address](#InvalidIpv6Address) +6. [Invalid IP Address](#InvalidIPAddress) +7. [Invalid TTL](#InvalidTTL) +8. [Invalid Batch Record Type](#InvalidBatchRecordType) +9. [Zone Discovery Failed](#ZoneDiscoveryFailed) +10. [Record Already Exists](#RecordAlreadyExists) +11. [Record Does Not Exist](#RecordDoesNotExist) +12. [CNAME Conflict](#CNAMEConflict) +13. [User Is Not Authorized](#UserIsNotAuthorized) +14. [Record Name Not Unique In Batch Change](#RecordNameNotUniqueInBatchChange) +15. [Invalid Record Type In Reverse Zone](#InvalidRecordTypeInReverseZone) + + +#### 1. Invalid Domain Name + +##### Error Message: + +``` +Invalid domain name: "", valid domain names must be letters, numbers, and hyphens, joined by dots, and terminate with a dot. +``` + +##### Details: + +Fully qualified domain names, must be comprised of **labels**, separated by dots. +A **label** is a combination of letters, digits, and hyphens. +They must also be absolute, which means they end with a dot. + +Syntax: + +``` + ::= | " " + + ::=

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 Q1Ogs}nT=_kv0LoF0DZGzhX4Qo literal 0 HcmV?d00001 diff --git a/modules/docs/src/main/tut/index.md b/modules/docs/src/main/tut/index.md new file mode 100644 index 0000000000..8809fa22db --- /dev/null +++ b/modules/docs/src/main/tut/index.md @@ -0,0 +1,23 @@ +--- +layout: home +title: "Home" +section: "home" +position: 1 +--- + +# Welcome + +VinylDNS is a DNS Management Platform that provides safe and convenient access +for your DNS needs. + +Features Include: + +- Connect to an _existing_ DNS zone +- Define permissions to govern record access for your zone(s) +- Create, Update, Delete DNS records in your zone(s) +- Search for Records in your zone(s) +- View every change to every record in your zone(s) +- Manage Groups and Members +- Synchronize VinylDNS with records from DNS + +[Click Here to Get Started!](./apidocs/basics) diff --git a/modules/portal/.gitignore b/modules/portal/.gitignore new file mode 100644 index 0000000000..b2709c0bee --- /dev/null +++ b/modules/portal/.gitignore @@ -0,0 +1,13 @@ +/conf/local.conf +__pycache__ +*.pyc +/func_test/.virtualenv +bower_components/ +node_modules/ +public/javascripts/ +public/stylesheets/ +public/custom/views.vinyl.js +package-lock.json +release.version +private +public/gentelella diff --git a/modules/portal/Gruntfile.js b/modules/portal/Gruntfile.js new file mode 100644 index 0000000000..e387f5a57e --- /dev/null +++ b/modules/portal/Gruntfile.js @@ -0,0 +1,85 @@ +/*global module:false*/ +module.exports = function (grunt) { + + // Project configuration. + grunt.initConfig({ + // Metadata. + pkg: grunt.file.readJSON('package.json'), + copy: { + main: { + files: [ + // includes files within path and its sub-directories + { expand: true, flatten: true, src: ['node_modules/jquery/dist/jquery.min.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['node_modules/angular/angular.min.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['node_modules/angular-animate/angular-animate.min.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['node_modules/angular-bootstrap/ui-bootstrap.min.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['node_modules/bootstrap/dist/js/bootstrap.min.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['node_modules/angular-ui-router/release/angular-ui-router.min.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['node_modules/bootstrap/dist/css/bootstrap.min.css'], dest: 'public/stylesheets' }, + { expand: true, flatten: true, src: ['node_modules/font-awesome/css/font-awesome.min.css'], dest: 'public/stylesheets' }, + { expand: true, cwd: 'node_modules/gentelella', dest: 'public/gentelella', src: '**'}, + { expand: true, flatten: true, src: ['public/custom/**/*.js', '!public/custom/**/*.spec.js'], dest: 'public/javascripts' }, + { expand: true, flatten: true, src: ['public/custom/**/*.css'], dest: 'public/stylesheets' } + ] + }, + unit: { + files: [ + { expand: true, flatten: true, src: ['node_modules/angular-mocks/angular-mocks.js'], dest: 'public/test_frameworks' }, + { expand: true, flatten: true, src: ['node_modules/jasmine-jquery/lib/jasmine-jquery.js'], dest: 'public/test_frameworks' }, + ] + } + }, + injector: { + local_dependencies: { + files: { + 'app/views/main.scala.html': [ + 'public/gentelella/vendors/jquery/dist/jquery.min.js', + 'public/javascripts/angular.min.js', + 'public/gentelella/vendors/bootstrap/dist/js/bootstrap.min.js', + 'public/javascripts/ui-bootstrap.min.js', + 'public/lib/**/*.module.js', + 'public/lib/**/*.js', + 'public/app.js', + 'public/gentelella/build/js/custom.js', + 'public/js/custom.js', + '!public/lib/**/*.spec.js' + ] + } + } + }, + karma: { + unit: { + configFile: 'karma.conf.js' + } + }, + clean: { + js: ['public/javascripts/*'], + css: ['public/stylesheets/*'], + unit: ['public/test_frameworks/*'], + gentelella: ['public/gentelella/*'] + }, + ngtemplates: { + 'views.vinyl': { + src: 'public/custom/**/*.html', + dest: 'public/custom/views.vinyl.js', + options: { + standalone: true, + quotes: 'single', + url: function (url) { return url.replace('public/custom/', ''); } + } + } + } + }); + + grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.loadNpmTasks('grunt-injector'); + grunt.loadNpmTasks('grunt-contrib-clean'); + grunt.loadNpmTasks('grunt-angular-templates'); + grunt.loadNpmTasks('grunt-karma'); + grunt.loadNpmTasks('grunt-mocha-phantomjs'); + + // Default task + grunt.registerTask('default', ['clean', 'ngtemplates', 'copy:main', 'injector']); + // Unit tests + grunt.registerTask('unit', ['default', 'ngtemplates', 'copy:unit', 'karma:unit', 'clean:unit']); +}; diff --git a/modules/portal/README.md b/modules/portal/README.md new file mode 100644 index 0000000000..da379c05bc --- /dev/null +++ b/modules/portal/README.md @@ -0,0 +1,95 @@ +# Vinyl Portal +Supplies a UI for and offers authentication into Vinyl, a DNSaaS offering. + +# Running Unit Tests +First, startup sbt: `sbt`. + +Next, you can run all tests by simply running `test`, or you can run an individual test by running `test-only *MySpec` + +# Running Frontend Tests +The frontend code is tested using Jasmine, spec files are stored in the same directory as the angular js files. +For example, the public/lib/controllers has both the controller files and the specs for those controllers. To run +these tests the command is `grunt unit` + +# Running Functional Tests +As of now, we have a functional testing harness that gets things set up, and a single test which tests if the login page +loads successfully. Run the following commands from the vinyl-portal folder as well, we are not using a VM for testing +at this time. + +`./run_all_tests.sh` will run the unit tests (`sbt clean coverage test`), and then set up and run the func tests + +`./run_func_tests.sh` will only set up and run the func tests + +# Building Locally + +1. You must have npm, if you don't have npm, follow instructions here . +2. Run `npm install` to install all dependencies, this includes those needed for testing. If you just want to run the portal then `npm install --production` would suffice +3. You must have grunt, if you don't have grunt, run `npm install -g grunt`. Then run `grunt default` from the root of the portal project +4. Create a local.conf file in the portal conf folder. Include in it the following information: + +``` +LDAP { + user = [get this from CAP member] + password = [get this from CAP member] + domain = [get this from CAP member] + searchBase = [get this from CAP member] + context.providerUrl = [get this from CAP member] +} +portal.vinyldns.backend.url = "http://127.0.0.1:9000" +portal.dynamo_delay=0 +dynamo { + key = "local" + secret = "local" + endpoint = "http://127.0.0.1:19000" +} +users { + dummy = false + tablename = "users" + provisionedReadThroughput = 100 + provisionedWriteThroughput = 100 +} +changelog { + dummy = false + tablename = "usersAndGroupChanges" + provisionedReadThroughput = 100 + provisionedWriteThroughput = 100 +} +``` + +5. Follow the instructions for building vinyl locally on the vinyl readme +6. Start vinyl with `sbt run`. Vinyl will start on localhost on port 9000. +7. Run the portal with `sbt -Djavax.net.ssl.trustStore="./private/trustStore.jks" -Dhttp.port=8080 run` +8. In a web browser go to localhost:8080 + +# Working locally +Often times as a developer you want to work with the portal locally in a "real" setting against your own LDAP +server. If your LDAP server requires SSL certs, you will need to create a trust store (or register the +SSL certs on your local machine). If you create a trust store, you will need to make the trust store +available so that you can start the portal locally and test. + +1. Create a trust store and save your certs. +1. Pass the trust store in when you start sbt. This can be on the command line like... +`sbt -Djavax.net.ssl.trustStore="./private/trustStore.jks"` + +# Updating the trustStore Certificates +Sometime on or before May 05, 2020 the certificates securing the AD servers will need to be renewed and updated. +When this happens or some other event causes the LDAP lookup to fail because of SSL certificate issues, follow +the following steps to update the trustStore with the new certificates. +- Get the new certificate with `openssl s_client -connect :`. This will display the certificate on the screen. +- Copy everything from `-----BEGIN CERTIFICATE-----` to `-----END CERTIFICATE-----` including the begin and end markers to the clipboard. +- Open a new file in your favorite text editor +- Paste the clipboard contents into the file +- Save the file and give it a name to be used in the next steps. (ex. `new-ad-ssl-cert.pem`) +- `keytool -printcert -file ` to view the file and check for errors +- `keytool -importcert -file -keystore trustStore.jks -alias ` (ex. `new-ad-cert`) +- Enter the trustStore password when prompted (look in application.conf) +- Answer yes to trust the certificate + +The trustStore is now updated with the new certificate. You can delete the certificate file it is no longer needed. + +# Credits + +* [logback-classic](https://github.com/qos-ch/logback) - [Eclipse Public License 1.0](https://www.eclipse.org/legal/epl-v10.html) +* [logback-core](https://github.com/qos-ch/logback) - [Eclipse Public License 1.0](https://www.eclipse.org/legal/epl-v10.html) +* [htmlunit](http://htmlunit.sourceforge.net/) + * [htmlunit-core-js](https://github.com/HtmlUnit/htmlunit-core-js) - [Mozilla Public License v2.0](https://www.mozilla.org/en-US/MPL/2.0/) diff --git a/modules/portal/app/Module.scala b/modules/portal/app/Module.scala new file mode 100644 index 0000000000..6b2430d3e6 --- /dev/null +++ b/modules/portal/app/Module.scala @@ -0,0 +1,90 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.dynamodbv2.{AmazonDynamoDBClient, AmazonDynamoDBClientBuilder} +import com.google.inject.AbstractModule +import controllers._ +import controllers.datastores.{ + DynamoDBChangeLogStore, + DynamoDBUserAccountStore, + InMemoryChangeLogStore, + InMemoryUserAccountStore +} +import play.api.{Configuration, Environment} +import vinyldns.core.crypto.CryptoAlgebra + +class Module(environment: Environment, configuration: Configuration) extends AbstractModule { + + val settings = new Settings(configuration) + + def configure(): Unit = { + val crypto = CryptoAlgebra.load(configuration.underlying.getConfig("crypto")).unsafeRunSync() + bind(classOf[Authenticator]).toInstance(authenticator()) + bind(classOf[UserAccountStore]).toInstance(userAccountStore(crypto)) + bind(classOf[ChangeLogStore]).toInstance(changeLogStore(crypto)) + } + + private def authenticator(): Authenticator = + /** + * Why not load config here you ask? Well, there is some ugliness in the LdapAuthenticator + * that I am not looking to undo at this time. There are private classes + * that do some wrapping. It all seems to work, so I am leaving it alone + * to complete the Play framework upgrade + */ + LdapAuthenticator(settings) + + private def userAccountStore(crypto: CryptoAlgebra) = { + val useDummy = configuration.get[Boolean]("users.dummy") + if (useDummy) + new InMemoryUserAccountStore + else { + // Important! For some reason the basic credentials get lost in Jenkins. Set the aws system properties + // just in case + val dynamoAKID = configuration.get[String]("dynamo.key") + val dynamoSecret = configuration.get[String]("dynamo.secret") + val dynamoEndpoint = configuration.get[String]("dynamo.endpoint") + val credentials = new BasicAWSCredentials(dynamoAKID, dynamoSecret) + val dynamoClient = AmazonDynamoDBClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(dynamoEndpoint, "us-east-1")) + .build() + .asInstanceOf[AmazonDynamoDBClient] + new DynamoDBUserAccountStore(dynamoClient, configuration, crypto) + } + } + + private def changeLogStore(crypto: CryptoAlgebra) = { + val useDummy = configuration.get[Boolean]("changelog.dummy") + if (useDummy) + new InMemoryChangeLogStore + else { + val dynamoAKID = configuration.get[String]("dynamo.key") + val dynamoSecret = configuration.get[String]("dynamo.secret") + val dynamoEndpoint = configuration.get[String]("dynamo.endpoint") + val credentials = new BasicAWSCredentials(dynamoAKID, dynamoSecret) + val dynamoClient = AmazonDynamoDBClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(dynamoEndpoint, "us-east-1")) + .build() + .asInstanceOf[AmazonDynamoDBClient] + new DynamoDBChangeLogStore(dynamoClient, configuration, crypto) + } + } +} diff --git a/modules/portal/app/controllers/ChangeLogStore.scala b/modules/portal/app/controllers/ChangeLogStore.scala new file mode 100644 index 0000000000..f67fa27fb3 --- /dev/null +++ b/modules/portal/app/controllers/ChangeLogStore.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import models.UserAccount +import org.joda.time.DateTime + +import scala.util.Try + +// $COVERAGE-OFF$ +object ChangeType { + def apply(s: String): ChangeType = + s.toLowerCase match { + case "created" => Create + case "updated" => Update + case "deleted" => Delete + case _ => throw new IllegalArgumentException(s"$s is not a valid change type") + } +} + +sealed trait ChangeType +case object Create extends ChangeType { + override val toString = "created" +} +case object Update extends ChangeType { + override val toString = "updated" +} +case object Delete extends ChangeType { + override val toString = "deleted" +} + +sealed trait ChangeLogMessage +final case class UserChangeMessage( + userId: String, + username: String, + timeStamp: DateTime, + changeType: ChangeType, + updatedUser: UserAccount, + previousUser: Option[UserAccount]) + extends ChangeLogMessage + +trait ChangeLogStore { + def log(change: ChangeLogMessage): Try[ChangeLogMessage] +} +// $COVERAGE-ON$ diff --git a/modules/portal/app/controllers/FrontendController.scala b/modules/portal/app/controllers/FrontendController.scala new file mode 100644 index 0000000000..8c006563a8 --- /dev/null +++ b/modules/portal/app/controllers/FrontendController.scala @@ -0,0 +1,109 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import javax.inject.{Inject, Singleton} +import models.CustomLinks +import org.slf4j.LoggerFactory +import play.api.Logger +import play.api.mvc._ +import play.api.Configuration + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +/* + * Controller for specific pages - sends requests along to views + */ +@Singleton +class FrontendController @Inject()(components: ControllerComponents, configuration: Configuration) + extends AbstractController(components) { + + import VinylDNS.withAuthenticatedUser + + implicit lazy val customLinks: CustomLinks = CustomLinks(configuration) + private val logger = LoggerFactory.getLogger(classOf[FrontendController]) + + def loginPage(): Action[AnyContent] = Action { implicit request => + request.session.get("username") match { + case Some(_) => Redirect("/index") + case None => + val flash = request.flash + Logger.error(s"$flash") + VinylDNS.Alerts.fromFlash(flash) match { + case Some(VinylDNS.Alert("danger", message)) => + Ok(views.html.login(Some(message))) + case _ => + Ok(views.html.login()) + } + } + } + + def logout(): Action[AnyContent] = Action { implicit request => + Redirect("/login").withNewSession + } + + def index(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { username => + Future(Ok(views.html.zones.zones(username))) + } + } + + def viewAllGroups(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { username => + Future(Ok(views.html.groups.groups(username))) + } + } + + def viewGroup(groupId: String): Action[AnyContent] = Action.async { implicit request => + logger.info(s"View group for $groupId") + withAuthenticatedUser { username => + Future(Ok(views.html.groups.groupDetail(username))) + } + } + + def viewAllZones(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { username => + Future(Ok(views.html.zones.zones(username))) + } + } + + def viewZone(zoneId: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { username => + Future(Ok(views.html.zones.zoneDetail(username, zoneId))) + } + } + + def viewAllBatchChanges(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { username => + Future(Ok(views.html.batchChanges.batchChanges(username))) + } + } + + def viewBatchChange(batchId: String): Action[AnyContent] = Action.async { implicit request => + logger.info(s"View Batch Change for $batchId") + withAuthenticatedUser { username => + Future(Ok(views.html.batchChanges.batchChangeDetail(username))) + } + } + + def viewNewBatchChange(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { username => + Future(Ok(views.html.batchChanges.batchChangeNew(username))) + } + } +} diff --git a/modules/portal/app/controllers/LdapAuthenticator.scala b/modules/portal/app/controllers/LdapAuthenticator.scala new file mode 100644 index 0000000000..af74d38d81 --- /dev/null +++ b/modules/portal/app/controllers/LdapAuthenticator.scala @@ -0,0 +1,242 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import java.util + +import controllers.LdapAuthenticator.LdapByDomainAuthenticator +import javax.naming.Context +import javax.naming.directory._ + +import scala.collection.JavaConverters._ +import scala.util.{Failure, Try} + +case class UserDetails( + nameInNamespace: String, + username: String, + email: Option[String], + firstName: Option[String], + lastName: Option[String]) + +object UserDetails { + private def getValue(attributes: Attributes, attributeName: String): Option[String] = + Option(attributes.get(attributeName)).map(_.get.toString) + + def apply(rawResult: SearchResult): UserDetails = { + val attributes = rawResult.getAttributes + val nameInNamespace = rawResult.getNameInNamespace + val username = attributes.get("sAMAccountName").get.toString + val email = getValue(attributes, "mail") + val firstName = getValue(attributes, "givenName") + val lastName = getValue(attributes, "sn") + + new UserDetails(nameInNamespace, username, email, firstName, lastName) + } +} + +case class ServiceAccount(domain: String, name: String, password: String) + +// $COVERAGE-OFF$ +object LdapAuthenticator { + type ContextCreator = (String, String) => Try[DirContext] + + /** + * contains the functionality to build an ldap directory context; essentially isolates ldap connection stuff from + * unit testable code + */ + val createContext: Settings => ContextCreator = (settings: Settings) => + (username: String, password: String) => { + val env: Map[String, String] = Map( + Context.INITIAL_CONTEXT_FACTORY -> settings.ldapCtxFactory, + Context.SECURITY_AUTHENTICATION -> settings.ldapSecurityAuthentication, + Context.SECURITY_PRINCIPAL -> username, + Context.SECURITY_CREDENTIALS -> password, + Context.PROVIDER_URL -> settings.ldapProviderUrl.toString + ) + + val searchScope = 2 //recursive + val searchControls = new SearchControls() + searchControls.setSearchScope(searchScope) + + val envHashtable = new util.Hashtable[String, String](env.asJava) + + Try(new InitialDirContext(envHashtable)) + } + // $COVERAGE-ON$ + + private[controllers] object LdapByDomainAuthenticator { + def apply(settings: Settings): LdapByDomainAuthenticator = + new LdapByDomainAuthenticator(settings, createContext(settings)) + + def apply(): LdapByDomainAuthenticator = LdapByDomainAuthenticator(Settings) + } + + /** + * Although this extra abstraction seems silly at first, it basically helps us remove one extra branch in our unit + * tests, making everything far more testable + * + * @param createContext function that creates the ldap connection / [DirContext] + */ + private[controllers] class LdapByDomainAuthenticator( + settings: Settings, + createContext: ContextCreator) { + + private val SEARCH_BASE = settings.ldapSearchBase + .map(searchDomain ⇒ searchDomain.organization → searchDomain.domainName) + .toMap + + private[controllers] def authenticate( + searchDomain: LdapSearchDomain, + username: String, + password: String): Try[UserDetails] = + createContext(s"${searchDomain.organization}\\$username", password).map { context => + try { + val searchControls = new SearchControls() + searchControls.setSearchScope(2) + val result = context.search( + SEARCH_BASE(searchDomain.organization), + s"(sAMAccountName=$username)", + searchControls) + if (!result.hasMore) + throw new UserDoesNotExistException( + s"[$username] can authenticate but LDAP entity " + + s"does not exist") + + UserDetails(result.next()) + + } finally { + // try to close but don't care if anything happens + Try(context.close()) + } + } + + private[controllers] def lookup( + searchDomain: LdapSearchDomain, + user: String, + serviceAccount: ServiceAccount): Try[UserDetails] = + createContext(s"${serviceAccount.domain}\\${serviceAccount.name}", serviceAccount.password) + .map { context => + try { + val searchControls = new SearchControls() + searchControls.setSearchScope(2) + val result = context.search( + SEARCH_BASE(searchDomain.organization), + s"(sAMAccountName=$user)", + searchControls) + if (!result.hasMore) + throw new UserDoesNotExistException(s"[$user] LDAP entity does not exist") + + UserDetails(result.next()) + + } finally { + // try to close but don't care if anything happens + Try(context.close()) + } + } + } + + def apply(settings: Settings): Authenticator = { + val testLogin = settings.portalTestLogin + val serviceUser = settings.ldapUser + val servicePass = settings.ldapPwd + val serviceDomain = settings.ldapDomain + val serviceAccount = ServiceAccount(serviceDomain, serviceUser, servicePass) + + if (testLogin) + new TestAuthenticator( + new LdapAuthenticator( + settings.ldapSearchBase, + LdapByDomainAuthenticator(settings), + serviceAccount)) + else + new LdapAuthenticator( + settings.ldapSearchBase, + LdapByDomainAuthenticator(settings), + serviceAccount) + } +} + +class UserDoesNotExistException(message: String) extends Exception(message: String) + +/** + * Top level ldap authenticator that tries authenticating on both the cable and corphq domains. Authentication is + * delegated to [LdapByDomainAuthenticator] + * + * @param authenticator does authentication by domain (ie cable vs corphq) + */ +class LdapAuthenticator( + searchBase: List[LdapSearchDomain], + authenticator: LdapByDomainAuthenticator, + serviceAccount: ServiceAccount) + extends Authenticator { + + private def findUserDetails( + domains: List[LdapSearchDomain], + userName: String, + f: LdapSearchDomain => Try[UserDetails]): Try[UserDetails] = domains match { + case Nil => Failure(new UserDoesNotExistException(s"[$userName] LDAP entity does not exist")) + case h :: t => f(h).recoverWith { case _ => findUserDetails(t, userName, f) } + } + + def authenticate(username: String, password: String): Try[UserDetails] = + findUserDetails(searchBase, username, authenticator.authenticate(_, username, password)) + + def lookup(username: String): Try[UserDetails] = + findUserDetails(searchBase, username, authenticator.lookup(_, username, serviceAccount)) + +} + +trait Authenticator { + def authenticate(username: String, password: String): Try[UserDetails] + def lookup(username: String): Try[UserDetails] +} + +/** + * Top level authenticator that has a bypass user for testing + * + * @param authenticator the real authenticator for when the user is not the test user + */ +class TestAuthenticator(authenticator: Authenticator) extends Authenticator { + private val testUserDetails = UserDetails( + "O=test,OU=testdata,CN=testuser", + "testuser", + Some("test@test.test"), + Some("Test"), + Some("User")) + private val recordPagingTestUserDetails = UserDetails( + "O=test,OU=testdata,CN=recordPagingTestUser", + "recordPagingTestUser", + Some("test@test.test"), + Some("Test"), + Some("User")) + + def authenticate(username: String, password: String): Try[UserDetails] = + (username, password) match { + case ("recordPagingTestUser", "testpassword") => Try(recordPagingTestUserDetails) + case ("testuser", "testpassword") => Try(testUserDetails) + case _ => authenticator.authenticate(username, password) + } + + def lookup(username: String): Try[UserDetails] = + username match { + case "recordPagingTestUser" => Try(recordPagingTestUserDetails) + case "testuser" => Try(testUserDetails) + case _ => authenticator.lookup(username) + } +} + +case class LdapSearchDomain(organization: String, domainName: String) diff --git a/modules/portal/app/controllers/Settings.scala b/modules/portal/app/controllers/Settings.scala new file mode 100644 index 0000000000..fb69b9ecc7 --- /dev/null +++ b/modules/portal/app/controllers/Settings.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import java.net.URI + +import com.typesafe.config.{Config, ConfigFactory} +import play.api.{ConfigLoader, Configuration} + +import scala.collection.JavaConverters._ + +class Settings(private val config: Configuration) { + + val ldapUser: String = config.get[String]("LDAP.user") + val ldapPwd: String = config.get[String]("LDAP.password") + val ldapDomain: String = config.get[String]("LDAP.domain") + + val ldapSearchBase: List[LdapSearchDomain] = config.get[List[LdapSearchDomain]]("LDAP.searchBase") + val ldapCtxFactory: String = config.get[String]("LDAP.context.initialContextFactory") + val ldapSecurityAuthentication: String = config.get[String]("LDAP.context.securityAuthentication") + val ldapProviderUrl: URI = new URI(config.get[String]("LDAP.context.providerUrl")) + + val portalTestLogin: Boolean = config.getOptional[Boolean]("portal.test_login").getOrElse(false) + + implicit def ldapSearchDomainLoader: ConfigLoader[List[LdapSearchDomain]] = + new ConfigLoader[List[LdapSearchDomain]] { + def load(config: Config, path: String): List[LdapSearchDomain] = { + val domains = config.getConfigList(path).asScala.map { domainConfig ⇒ + val org = domainConfig.getString("organization") + val domain = domainConfig.getString("domainName") + LdapSearchDomain(org, domain) + } + domains.toList + } + } +} + +object Settings extends Settings(Configuration(ConfigFactory.load())) diff --git a/modules/portal/app/controllers/UserAccountAccessor.scala b/modules/portal/app/controllers/UserAccountAccessor.scala new file mode 100644 index 0000000000..64ad1da240 --- /dev/null +++ b/modules/portal/app/controllers/UserAccountAccessor.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import javax.inject.{Inject, Singleton} + +import models.UserAccount +import org.joda.time.DateTime + +import scala.util.{Failure, Success, Try} + +case class UserChangeRecord( + changeId: Long, + userId: String, + user: String, + timeStamp: DateTime, + changeType: UserChangeType, + newUser: UserAccount, + oldUser: UserAccount) + +@Singleton +class UserAccountAccessor @Inject()(store: UserAccountStore) { + + /** + * Lookup a user in the store. Using identifier as the user id and/or name + * + * @param identifier + * @return Success(Some(user account)) on success, Success(None) if the user does not exist and Failure when there + * was an error. + */ + def get(identifier: String): Try[Option[UserAccount]] = + store.getUserById(identifier) match { + case Success(None) => store.getUserByName(identifier) + case Success(Some(user)) => Success(Some(user)) + case Failure(ex) => Failure(ex) + } + + def put(user: UserAccount): Try[UserAccount] = + store.storeUser(user) + + def getUserByKey(key: String): Try[Option[UserAccount]] = + store.getUserByKey(key) +} diff --git a/modules/portal/app/controllers/UserAccountStore.scala b/modules/portal/app/controllers/UserAccountStore.scala new file mode 100644 index 0000000000..4fc0fff4ee --- /dev/null +++ b/modules/portal/app/controllers/UserAccountStore.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import models.UserAccount + +import scala.util.Try + +// $COVERAGE-OFF$ + +trait UserAccountStore { + def getUserById(userId: String): Try[Option[UserAccount]] + def getUserByName(username: String): Try[Option[UserAccount]] + def getUserByKey(accessKey: String): Try[Option[UserAccount]] + def storeUser(user: UserAccount): Try[UserAccount] +} + +sealed trait UserChangeType +final case object Created extends UserChangeType +final case object Updated extends UserChangeType +final case object Deleted extends UserChangeType +// $COVERAGE-ON$ diff --git a/modules/portal/app/controllers/VinylDNS.scala b/modules/portal/app/controllers/VinylDNS.scala new file mode 100644 index 0000000000..591db91f12 --- /dev/null +++ b/modules/portal/app/controllers/VinylDNS.scala @@ -0,0 +1,632 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import java.util + +import com.amazonaws.auth.{AWSCredentials, BasicAWSCredentials, SignerFactory} +import models.{SignableVinylDNSRequest, UserAccount, VinylDNSRequest} +import org.joda.time.DateTime +import play.api.{Logger, _} +import play.api.data.Form +import play.api.data.Forms._ +import play.api.libs.json._ +import play.api.libs.ws.WSClient +import play.api.mvc._ +import java.util.HashMap +import javax.inject.{Inject, Singleton} + +import scala.collection.JavaConverters._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +object VinylDNS { + + import play.api.mvc._ + + object Alerts { + private val TYPE = "alertType" + private val MSG = "alertMessage" + def error(msg: String): Flash = Flash(Map(TYPE -> "danger", MSG -> msg)) + def warning(msg: String): Flash = Flash(Map(TYPE -> "warning", MSG -> msg)) + def info(msg: String): Flash = Flash(Map(TYPE -> "info", MSG -> msg)) + def success(msg: String): Flash = Flash(Map(TYPE -> "success", MSG -> msg)) + + def fromFlash(flash: Flash): Option[Alert] = + (flash.get(TYPE), flash.get(MSG)) match { + case (Some(alertType), Some(alertMessage)) => Some(Alert(alertType, alertMessage)) + case _ => None + } + } + case class Alert(alertType: String, message: String) + + case class UserInfo( + userName: String, + firstName: Option[String], + lastName: Option[String], + email: Option[String], + isSuper: Boolean, + id: String) + object UserInfo { + def fromAccount(account: UserAccount): UserInfo = + UserInfo( + userName = account.username, + firstName = account.firstName, + lastName = account.lastName, + email = account.email, + isSuper = account.isSuper, + id = account.userId + ) + } + + private[controllers] def withAuthenticatedUser(block: String => Future[Result])( + implicit request: Request[AnyContent]): Future[Result] = { + import Results.Redirect + + request.session.get("username") match { + case Some(username) => block(username) + case None => + Future( + Redirect("/login").flashing( + Alerts.warning("You are not logged in. Please login to continue."))) + } + } +} + +@Singleton +class VinylDNS @Inject()( + configuration: Configuration, + authenticator: Authenticator, + userAccountAccessor: UserAccountAccessor, + auditLogAccessor: ChangeLogStore, + wsClient: WSClient, + components: ControllerComponents) + extends AbstractController(components) { + + import VinylDNS._ + import play.api.mvc._ + + private val signer = SignerFactory.getSigner("VinylDNS", "us/east") + private val vinyldnsServiceBackend = + configuration + .getOptional[String]("portal.vinyldns.backend.url") + .getOrElse("http://localhost:9000") + private val cacheHeaders = Seq( + ("Cache-Control", "no-cache, no-store, must-revalidate"), + ("Pragma", "no-cache"), + ("Expires", "0")) + + implicit val userInfoReads: Reads[VinylDNS.UserInfo] = Json.reads[VinylDNS.UserInfo] + implicit val userInfoWrites: Writes[VinylDNS.UserInfo] = Json.writes[VinylDNS.UserInfo] + + def login(): Action[AnyContent] = Action { implicit request => + val userForm = Form( + tuple( + "username" -> text, + "password" -> text + ) + ) + val (username, password) = userForm.bindFromRequest.get + + processLogin(username, password) + } + + def newGroup(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val json = request.body.asJson + val payload = json.map(Json.stringify) + val vinyldnsRequest = + new VinylDNSRequest("POST", s"$vinyldnsServiceBackend", "groups", payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Logger.info(response.body) + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def getGroup(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"groups/$id") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Logger.info(s"group [$id] retrieved with status [${response.status}]") + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def deleteGroup(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = VinylDNSRequest("DELETE", s"$vinyldnsServiceBackend", s"groups/$id") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Logger.info(s"group [$id] deleted with status [${response.status}]") + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def updateGroup(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val payload = request.body.asJson.map(Json.stringify) + val vinyldnsRequest = + VinylDNSRequest("PUT", s"$vinyldnsServiceBackend", s"groups/$id", payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Logger.info(s"group [$id] updated with status [${response.status}]") + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def getMyGroups(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser({ _ => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + + val vinyldnsRequest = + VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"groups", parameters = queryParameters) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + }) + } + + def getAuthenticatedUserData(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { user => + val response = userAccountAccessor.get(user).map { + case Some(userDetails) => + Ok(Json.toJson(VinylDNS.UserInfo.fromAccount(userDetails))) + .withHeaders(cacheHeaders: _*) + case _ => + Status(404)(s"Did not find user data for '$user'") + } + Future.fromTry(response) + } + } + + private def processCsv(username: String, account: UserAccount): Result = + account.username match { + case accountUsername: String if accountUsername == username => + Logger.info( + s"Sending credentials for user=$username with key accessKey=${account.accessKey}") + Ok( + s"NT ID, access key, secret key,api url\n%s,%s,%s,%s" + .format( + account.username, + account.accessKey, + account.accessSecret, + vinyldnsServiceBackend)) + .as("text/csv") + + case _ => + Redirect("/login").withNewSession + .flashing(VinylDNS.Alerts.error("Mismatched credentials - Please log in again")) + } + + def serveCredsFile(fileName: String): Action[AnyContent] = Action.async { implicit request => + Logger.info(s"Serving credentials for file $fileName") + withAuthenticatedUser { username => + userAccountAccessor.get(username) match { + case Success(Some(account)) => Future(processCsv(username, account)) + case Success(None) => + throw new UnsupportedOperationException(s"Error - User account for $username not found") + case Failure(ex) => throw ex + } + } + } + + private def createNewUser(details: UserDetails): Try[UserAccount] = { + val newAccount = + UserAccount(details.username, details.firstName, details.lastName, details.email) + for { + newUser <- userAccountAccessor.put(newAccount) + } yield { + auditLogAccessor.log( + UserChangeMessage( + newUser.userId, + newUser.username, + DateTime.now(), + ChangeType("created"), + newUser, + None)) + newUser + } + } + + def getUserDataByUsername(username: String): Action[AnyContent] = Action.async { + implicit request => + withAuthenticatedUser { _ => + Future + .fromTry { + for { + userDetails <- authenticator.lookup(username) + existingAccount <- userAccountAccessor.get(userDetails.username) + userAccount <- existingAccount match { + case Some(user) => Try(VinylDNS.UserInfo.fromAccount(user)) + case None => + createNewUser(userDetails).map(VinylDNS.UserInfo.fromAccount) + } + } yield userAccount + } + .map(Json.toJson(_)) + .map(Ok(_).withHeaders(cacheHeaders: _*)) + .recover { + case _: UserDoesNotExistException => NotFound(s"User $username was not found") + } + } + } + + def processLogin(username: String, password: String): Result = + authenticator.authenticate(username, password) match { + case Failure(error) => + Logger.error(s"Authentication failed for [$username]", error) + Redirect("/login").flashing( + VinylDNS.Alerts.error("Authentication failed, please try again")) + case Success(userDetails: UserDetails) => + Logger.info( + s"user [${userDetails.username}] logged in with ldap path [${userDetails.nameInNamespace}]") + + // get or create the new style user account + val userAccount = userAccountAccessor + .get(userDetails.username) + .flatMap { + case None => + Logger.info(s"Creating user account for ${userDetails.username}") + createNewUser(userDetails).map { + case user: UserAccount => + Logger.info(s"User account for ${user.username} created with id ${user.userId}") + user + } + case Some(user) => + Logger.info(s"User account for ${user.username} exists with id ${user.userId}") + Success(user) + } + .recoverWith { + case ex => + Logger.error( + s"User retrieval or creation failed for user ${userDetails.username} with message ${ex.getMessage}") + throw ex + } + .get + + Logger.info( + s"--NEW MEMBERSHIP-- user [${userAccount.username}] logged in with id [${userAccount.userId}]") + Redirect("/index") + .withSession("username" -> userAccount.username, "accessKey" -> userAccount.accessKey) + } + + def getZones: Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + val vinyldnsRequest = + new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + "zones", + parameters = queryParameters) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def getZone(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/$id") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def syncZone(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = + new VinylDNSRequest("POST", s"$vinyldnsServiceBackend", s"zones/$id/sync") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def getRecordSets(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + val vinyldnsRequest = VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"zones/$id/recordsets", + parameters = queryParameters) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def listRecordSetChanges(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + val vinyldnsRequest = new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"zones/$id/recordsetchanges", + parameters = queryParameters) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def getChanges(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/$id/history") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def addZone(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val json = request.body.asJson + val payload = json.map(Json.stringify) + val vinyldnsRequest = + new VinylDNSRequest("POST", s"$vinyldnsServiceBackend", "zones", payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def updateZone(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { user => + val json = request.body.asJson + val payload = json.map(Json.stringify) + val vinyldnsRequest = + new VinylDNSRequest("PUT", s"$vinyldnsServiceBackend", s"zones/$id", payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def addRecordSet(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val json = request.body.asJson + val payload = json.map(Json.stringify) + val vinyldnsRequest = + new VinylDNSRequest("POST", s"$vinyldnsServiceBackend", s"zones/$id/recordsets", payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def deleteZone(id: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = new VinylDNSRequest("DELETE", s"$vinyldnsServiceBackend", s"zones/$id") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def updateRecordSet(zid: String, rid: String): Action[AnyContent] = Action.async { + implicit request => + withAuthenticatedUser { _ => + val json = request.body.asJson + val payload = json.map(Json.stringify) + val vinyldnsRequest = + new VinylDNSRequest( + "PUT", + s"$vinyldnsServiceBackend", + s"zones/$zid/recordsets/$rid", + payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def deleteRecordSet(zid: String, rid: String): Action[AnyContent] = Action.async { + implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = + new VinylDNSRequest("DELETE", s"$vinyldnsServiceBackend", s"zones/$zid/recordsets/$rid") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def signRequest( + vinyldnsRequest: VinylDNSRequest, + credentials: AWSCredentials): SignableVinylDNSRequest = { + val signableRequest = new SignableVinylDNSRequest(vinyldnsRequest) + signer.sign(signableRequest, credentials) + signableRequest + } + + def getUserCreds(keyOption: Option[String]): BasicAWSCredentials = + keyOption match { + case Some(key) => + userAccountAccessor.getUserByKey(key) match { + case Success(Some(account)) => + new BasicAWSCredentials(account.accessKey, account.accessSecret) + case Success(None) => + throw new IllegalArgumentException( + s"Key [$key] Not Found!! Please logout then back in.") + case Failure(ex) => throw ex + } + case None => throw new IllegalArgumentException("No Key Found!!") + } + + private def extractParameters( + params: util.Map[String, util.List[String]]): Seq[(String, String)] = + params.asScala.foldLeft(Seq[(String, String)]()) { + case (acc, (key, values)) => + acc ++ values.asScala.map(v => key -> v) + } + + private def executeRequest(request: SignableVinylDNSRequest) = { + Logger.info(s"Request to send: [${request.getResourcePath}]") + wsClient + .url(request.getEndpoint.toString + "/" + request.getResourcePath) + .withHttpHeaders("Content-Type" -> request.contentType) + .withBody( + request.getOriginalRequestObject.asInstanceOf[VinylDNSRequest].payload.getOrElse("")) + .withHttpHeaders(request.getHeaders.asScala.toSeq: _*) + .withMethod(request.getHttpMethod.name()) + .withQueryStringParameters(extractParameters(request.getParameters): _*) + .execute() + } + + def getMemberList(groupId: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + + val vinyldnsRequest = new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"groups/$groupId/members", + parameters = queryParameters) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def getBatchChange(batchChangeId: String): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val vinyldnsRequest = + new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"zones/batchrecordchanges/$batchChangeId") + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def newBatchChange(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val json = request.body.asJson + val payload = json.map(Json.stringify) + val vinyldnsRequest = + new VinylDNSRequest("POST", s"$vinyldnsServiceBackend", "zones/batchrecordchanges", payload) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Logger.info(response.body) + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } + + def listBatchChanges(): Action[AnyContent] = Action.async { implicit request => + withAuthenticatedUser { _ => + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (startFrom, maxItems) <- request.queryString + } queryParameters.put(startFrom, maxItems.asJava) + val vinyldnsRequest = new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + "zones/batchrecordchanges", + parameters = queryParameters) + val signedRequest = + signRequest(vinyldnsRequest, getUserCreds(request.session.get("accessKey"))) + executeRequest(signedRequest).map(response => { + Logger.info(response.body) + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + } + } +} diff --git a/modules/portal/app/controllers/datastores/DynamoDBChangeLogStore.scala b/modules/portal/app/controllers/datastores/DynamoDBChangeLogStore.scala new file mode 100644 index 0000000000..9d1a103127 --- /dev/null +++ b/modules/portal/app/controllers/datastores/DynamoDBChangeLogStore.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import java.util + +import com.amazonaws.AmazonClientException +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient +import com.amazonaws.services.dynamodbv2.model._ +import controllers.{ChangeLogMessage, ChangeLogStore, UserChangeMessage} +import play.api.Configuration +import vinyldns.core.crypto.CryptoAlgebra + +import scala.util.Try + +class DynamoDBChangeLogStore( + dynamo: AmazonDynamoDBClient, + config: Configuration, + crypto: CryptoAlgebra) + extends ChangeLogStore { + + private val tableName = config.get[String]("changelog.tablename") + private val readThroughput = + config.getOptional[Long]("changelog.provisionedReadThroughput").getOrElse(1L) + private val writeThroughput = + config.getOptional[Long]("changelog.provisionedWriteThroughput").getOrElse(1L) + + private val TIME_STAMP = "timestamp" + + private val tableAttributes = Seq( + new AttributeDefinition(TIME_STAMP, "S") + ) + + try { + dynamo.describeTable(new DescribeTableRequest(tableName)) + } catch { + case _: AmazonClientException => + dynamo.createTable( + new CreateTableRequest() + .withTableName(tableName) + .withAttributeDefinitions(tableAttributes: _*) + .withKeySchema(new KeySchemaElement(TIME_STAMP, KeyType.HASH)) + .withProvisionedThroughput(new ProvisionedThroughput(readThroughput, writeThroughput))) + dynamo.describeTable(new DescribeTableRequest(tableName)) + } + + def log(change: ChangeLogMessage): Try[ChangeLogMessage] = + Try { + change match { + case ucm: UserChangeMessage => + dynamo.putItem(tableName, toDynamoItem(ucm)) + ucm + } + } + + def toDynamoItem(message: UserChangeMessage): java.util.HashMap[String, AttributeValue] = { + val item = new util.HashMap[String, AttributeValue]() + item.put("timestamp", new AttributeValue(message.timeStamp.toString)) + item.put("userId", new AttributeValue(message.userId)) + item.put("username", new AttributeValue(message.username)) + item.put("changeType", new AttributeValue(message.changeType.toString)) + item.put( + "updatedUser", + new AttributeValue().withM(DynamoDBUserAccountStore.toItem(message.updatedUser, crypto))) + message.previousUser match { + case Some(user) => + item.put( + "previousUser", + new AttributeValue().withM(DynamoDBUserAccountStore.toItem(user, crypto))) + case None => () + } + item + } +} diff --git a/modules/portal/app/controllers/datastores/DynamoDBUserAccountStore.scala b/modules/portal/app/controllers/datastores/DynamoDBUserAccountStore.scala new file mode 100644 index 0000000000..f933eca215 --- /dev/null +++ b/modules/portal/app/controllers/datastores/DynamoDBUserAccountStore.scala @@ -0,0 +1,214 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import controllers.UserAccountStore +import java.util + +import com.amazonaws.AmazonClientException +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient +import com.amazonaws.services.dynamodbv2.model._ +import models.UserAccount +import org.joda.time.DateTime +import play.api.Logger +import play.api.Configuration +import vinyldns.core.crypto.CryptoAlgebra + +import scala.util.Try +import scala.collection.JavaConverters._ + +object DynamoDBUserAccountStore { + def getAttributeOrNone( + items: java.util.Map[String, AttributeValue], + attribute: String): Option[String] = + Try(items.get(attribute).getS).toOption + + def fromItem(items: java.util.Map[String, AttributeValue], crypto: CryptoAlgebra): UserAccount = { + val superUser: Try[Boolean] = Try(items.get("super").getBOOL) + new UserAccount( + items.get("userid").getS, + items.get("username").getS, + getAttributeOrNone(items, "firstname"), + getAttributeOrNone(items, "lastname"), + getAttributeOrNone(items, "email"), + new DateTime(items.get("created").getN.toLong), + items.get("accesskey").getS, + crypto.decrypt(items.get("secretkey").getS), + superUser.getOrElse(false) + ) + } + + def toItem(ua: UserAccount, crypto: CryptoAlgebra): java.util.Map[String, AttributeValue] = { + val item = new util.HashMap[String, AttributeValue]() + item.put("userid", new AttributeValue().withS(ua.userId)) + item.put("username", new AttributeValue().withS(ua.username)) + ua.firstName.foreach { firstname => + item.put("firstname", new AttributeValue().withS(firstname)) + } + ua.lastName.foreach { lastname => + item.put("lastname", new AttributeValue().withS(lastname)) + } + ua.email.foreach { email => + item.put("email", new AttributeValue().withS(email)) + } + item.put("created", new AttributeValue().withN(ua.created.getMillis.toString)) + item.put("accesskey", new AttributeValue().withS(ua.accessKey)) + item.put("secretkey", new AttributeValue().withS(crypto.encrypt(ua.accessSecret))) + item + } +} + +class DynamoDBUserAccountStore( + dynamo: AmazonDynamoDBClient, + config: Configuration, + crypto: CryptoAlgebra) + extends UserAccountStore { + private val tableName = config.get[String]("users.tablename") + private val readThroughput = + config.getOptional[Long]("users.provisionedReadThroughput").getOrElse(1L) + private val writeThroughput = + config.getOptional[Long]("users.provisionedWriteThroughput").getOrElse(1L) + + private val USER_ID = "userid" + private val USER_NAME = "username" + private val USER_INDEX_NAME = "username_index" + private val ACCESS_KEY = "accesskey" + private val ACCESS_KEY_INDEX_NAME = "access_key_index" + + private val tableAttributes = Seq( + new AttributeDefinition(USER_ID, "S"), + new AttributeDefinition(USER_NAME, "S"), + new AttributeDefinition(ACCESS_KEY, "S") + ) + + private val gsis = Seq( + new GlobalSecondaryIndex() + .withIndexName(USER_INDEX_NAME) + .withProvisionedThroughput(new ProvisionedThroughput(readThroughput, writeThroughput)) + .withKeySchema(new KeySchemaElement(USER_NAME, KeyType.HASH)) + .withProjection(new Projection().withProjectionType("ALL")), + new GlobalSecondaryIndex() + .withIndexName(ACCESS_KEY_INDEX_NAME) + .withProvisionedThroughput(new ProvisionedThroughput(readThroughput, writeThroughput)) + .withKeySchema(new KeySchemaElement(ACCESS_KEY, KeyType.HASH)) + .withProjection(new Projection().withProjectionType("ALL")) + ) + + try { + dynamo.describeTable(new DescribeTableRequest(tableName)) + } catch { + case _: AmazonClientException => + dynamo.createTable( + new CreateTableRequest() + .withTableName(tableName) + .withAttributeDefinitions(tableAttributes: _*) + .withKeySchema(new KeySchemaElement(USER_ID, KeyType.HASH)) + .withGlobalSecondaryIndexes(gsis: _*) + .withProvisionedThroughput(new ProvisionedThroughput(readThroughput, writeThroughput))) + dynamo.describeTable(new DescribeTableRequest(tableName)) + } + + def getUserById(userId: String): Try[Option[UserAccount]] = { + val key = new util.HashMap[String, AttributeValue]() + key.put(USER_ID, new AttributeValue(userId)) + val request = new GetItemRequest() + .withTableName(tableName) + .withKey(key) + Try { + dynamo.getItem(request) match { + case null => None + // Amazon's client java docs state "If there is no matching item, GetItem does not return any data." + // that could mean the item has no data or a null is returned, so we need to handle both cases. + case result: GetItemResult if result.getItem == null => None + case result: GetItemResult if result.getItem.isEmpty => None + case result: GetItemResult => + Some(DynamoDBUserAccountStore.fromItem(result.getItem, crypto)) + } + } + } + + def getUserByName(username: String): Try[Option[UserAccount]] = { + val attributeNames = new util.HashMap[String, String]() + attributeNames.put("#uname", USER_NAME) + val attributeValues = new util.HashMap[String, AttributeValue]() + attributeValues.put(":uname", new AttributeValue().withS(username)) + val request = new QueryRequest() + .withTableName(tableName) + .withKeyConditionExpression("#uname = :uname") + .withExpressionAttributeNames(attributeNames) + .withExpressionAttributeValues(attributeValues) + .withIndexName(USER_INDEX_NAME) + Try { + dynamo.query(request) match { + case result: QueryResult if result.getCount == 1 => + Some(DynamoDBUserAccountStore.fromItem(result.getItems.get(0), crypto)) + case result: QueryResult if result.getCount == 0 => None + case result: QueryResult if result.getCount >= 2 => + val prefixString = "!!! INCONSISTENT DATA !!!" + Logger.error(s"$prefixString ${result.getCount} user accounts for ntid $username found!") + for { + item <- result.getItems.asScala + } { + val user = DynamoDBUserAccountStore.fromItem(item, crypto) + Logger.error(s"$prefixString ${user.username} has user ID of ${user.userId}") + } + Some(DynamoDBUserAccountStore.fromItem(result.getItems.get(0), crypto)) + } + } + } + + def getUserByKey(key: String): Try[Option[UserAccount]] = { + val attributeNames = new util.HashMap[String, String]() + attributeNames.put("#ukey", ACCESS_KEY) + val attributeValues = new util.HashMap[String, AttributeValue]() + attributeValues.put(":ukey", new AttributeValue().withS(key)) + val request = new QueryRequest() + .withTableName(tableName) + .withKeyConditionExpression("#ukey = :ukey") + .withExpressionAttributeNames(attributeNames) + .withExpressionAttributeValues(attributeValues) + .withIndexName(ACCESS_KEY_INDEX_NAME) + Try { + dynamo.query(request) match { + case result: QueryResult if result.getCount == 1 => + Some(DynamoDBUserAccountStore.fromItem(result.getItems.get(0), crypto)) + case result: QueryResult if result.getCount == 0 => None + case result: QueryResult if result.getCount >= 2 => + val prefixString = "!!! INCONSISTENT DATA !!!" + Logger.error(s"$prefixString ${result.getCount} user accounts for access key $key found!") + for { + item <- result.getItems.asScala + } { + val user = DynamoDBUserAccountStore.fromItem(item, crypto) + Logger.error(s"$prefixString ${user.username} has key of ${user.accessKey}") + } + Some(DynamoDBUserAccountStore.fromItem(result.getItems.get(0), crypto)) + } + } + } + + def storeUser(user: UserAccount): Try[UserAccount] = { + val item = DynamoDBUserAccountStore.toItem(user, crypto) + val request = new PutItemRequest().withItem(item).withTableName(tableName) + + Try { + dynamo.putItem(request) match { + case _: PutItemResult => user + } + } + } +} diff --git a/modules/portal/app/controllers/datastores/InMemoryChangeLogStore.scala b/modules/portal/app/controllers/datastores/InMemoryChangeLogStore.scala new file mode 100644 index 0000000000..f30e15449b --- /dev/null +++ b/modules/portal/app/controllers/datastores/InMemoryChangeLogStore.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import controllers.{ChangeLogMessage, ChangeLogStore, UserChangeMessage} + +import scala.collection.mutable +import scala.util.Try + +class InMemoryChangeLogStore extends ChangeLogStore { + type InMemoryLog = mutable.MutableList[ChangeLogMessage] + val userChangeLog = new InMemoryLog() + + def log(change: ChangeLogMessage): Try[ChangeLogMessage] = + Try { + change match { + case ucm: UserChangeMessage => + userChangeLog += ucm + ucm + } + } +} diff --git a/modules/portal/app/controllers/datastores/InMemoryUserAccountStore.scala b/modules/portal/app/controllers/datastores/InMemoryUserAccountStore.scala new file mode 100644 index 0000000000..5daea30c8c --- /dev/null +++ b/modules/portal/app/controllers/datastores/InMemoryUserAccountStore.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import controllers.UserAccountStore +import models.UserAccount + +import scala.collection.mutable +import scala.util.Try + +class InMemoryUserAccountStore extends UserAccountStore { + val users = new mutable.HashMap[String, UserAccount]() + val usersByNameIndex = new mutable.HashMap[String, String]() + val usersByKeyIndex = new mutable.HashMap[String, String]() + + def getUserById(userId: String): Try[Option[UserAccount]] = + Try(users.get(userId)) + + def getUserByName(username: String): Try[Option[UserAccount]] = + Try(usersByNameIndex.get(username)).flatMap { + case Some(userId) => getUserById(userId) + case None => Try(None) + } + + def getUserByKey(key: String): Try[Option[UserAccount]] = + Try(usersByKeyIndex.get(key)).flatMap { + case Some(userId) => getUserById(userId) + case None => Try(None) + } + + def storeUser(user: UserAccount): Try[UserAccount] = + Try { + users.put(user.userId, user) + usersByNameIndex.put(user.username, user.userId) + usersByKeyIndex.put(user.accessKey, user.userId) + user + } +} diff --git a/modules/portal/app/models/CustomLinks.scala b/modules/portal/app/models/CustomLinks.scala new file mode 100644 index 0000000000..6e1fdfce2b --- /dev/null +++ b/modules/portal/app/models/CustomLinks.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import com.typesafe.config.Config +import play.api.{ConfigLoader, Configuration} + +import scala.collection.JavaConverters._ + +case class CustomLinks(private val config: Configuration) { + val links: List[CustomLink] = + config.getOptional[List[CustomLink]]("links").getOrElse(List[CustomLink]()) + + implicit def customLinksLoader: ConfigLoader[List[CustomLink]] = + new ConfigLoader[List[CustomLink]] { + def load(config: Config, path: String): List[CustomLink] = { + val links = config.getConfigList(path).asScala.map { linkConfig => + val displayOnSidebar = linkConfig.getBoolean("displayOnSidebar") + val displayOnLoginScreen = linkConfig.getBoolean("displayOnLoginScreen") + val title = linkConfig.getString("title") + val href = linkConfig.getString("href") + val icon = linkConfig.getString("icon") + CustomLink(displayOnSidebar, displayOnLoginScreen, title, href, icon) + } + links.toList + } + } +} + +case class CustomLink( + displayOnSidebar: Boolean, + displayOnLoginScreen: Boolean, + title: String, + href: String, + icon: String) diff --git a/modules/portal/app/models/UserAccount.scala b/modules/portal/app/models/UserAccount.scala new file mode 100644 index 0000000000..b87f399523 --- /dev/null +++ b/modules/portal/app/models/UserAccount.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import java.util.UUID + +import org.apache.commons.lang3.RandomStringUtils +import org.joda.time.DateTime + +case class UserAccount( + userId: String, + username: String, + firstName: Option[String], + lastName: Option[String], + email: Option[String], + created: DateTime, + accessKey: String, + accessSecret: String, + isSuper: Boolean = false) { + + override def toString() = { + val sb = new StringBuilder + sb.append("UserAccount: [") + sb.append("id=\"").append(userId).append("\"; ") + sb.append("username=\"").append(username).append("\"; ") + sb.append("firstName=\"").append(firstName).append("\"; ") + sb.append("lastName=\"").append(lastName).append("\"; ") + sb.append("email=\"").append(email).append("\"; ") + sb.append("accessKey=\"").append(accessKey).append("\"; ") + sb.append("]") + sb.toString + } +} + +object UserAccount { + private def generateKey: String = RandomStringUtils.randomAlphanumeric(20) + + def apply( + username: String, + firstName: Option[String], + lastName: Option[String], + email: Option[String]): UserAccount = { + val userId = UUID.randomUUID().toString + val createdTime = DateTime.now() + val key = generateKey + val secret = generateKey + + UserAccount(userId, username, firstName, lastName, email, createdTime, key, secret, false) + } +} diff --git a/modules/portal/app/models/VinylRequest.scala b/modules/portal/app/models/VinylRequest.scala new file mode 100644 index 0000000000..a958cc5744 --- /dev/null +++ b/modules/portal/app/models/VinylRequest.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import java.io.{ByteArrayInputStream, InputStream} +import java.util + +import com.amazonaws.{ReadLimitInfo, SignableRequest} +import com.amazonaws.http.HttpMethodName + +object VinylDNSRequest { + val APPLICATION_JSON = "application/json" +} + +case class VinylDNSRequest( + method: String, + url: String, + path: String = "", + payload: Option[String] = None, + parameters: util.HashMap[String, java.util.List[String]] = + new util.HashMap[String, java.util.List[String]]()) + +class SignableVinylDNSRequest(origReq: VinylDNSRequest) extends SignableRequest[VinylDNSRequest] { + + import VinylDNSRequest._ + + val contentType: String = APPLICATION_JSON + + private val headers = new util.HashMap[String, String]() + private val parameters = origReq.parameters + private val uri = new java.net.URI(origReq.url) + // I hate to do this, but need to be able to set the content after creation to + // implement the interface properly + private var contentStream: InputStream = new ByteArrayInputStream( + origReq.payload.getOrElse("").getBytes("UTF-8")) + + override def addHeader(name: String, value: String): Unit = headers.put(name, value) + override def getHeaders: java.util.Map[String, String] = headers + override def getResourcePath: String = origReq.path + override def addParameter(name: String, value: String): Unit = { + if (!parameters.containsKey(name)) parameters.put(name, new util.ArrayList[String]()) + parameters.get(name).add(value) + } + override def getParameters: java.util.Map[String, java.util.List[String]] = parameters + override def getEndpoint: java.net.URI = uri + override def getHttpMethod: HttpMethodName = HttpMethodName.valueOf(origReq.method) + override def getTimeOffset: Int = 0 + override def getContent: InputStream = contentStream + override def getContentUnwrapped: InputStream = getContent + override def getReadLimitInfo: ReadLimitInfo = new ReadLimitInfo { + override def getReadLimit: Int = -1 + } + override def getOriginalRequestObject: Object = origReq + override def setContent(content: InputStream): Unit = contentStream = content +} diff --git a/modules/portal/app/views/batchChanges/batchChangeDetail.scala.html b/modules/portal/app/views/batchChanges/batchChangeDetail.scala.html new file mode 100644 index 0000000000..84d538573e --- /dev/null +++ b/modules/portal/app/views/batchChanges/batchChangeDetail.scala.html @@ -0,0 +1,135 @@ + +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) + +@content = { + +

+ + + + + + +
+

+ Batch Change + {{batch.status}} + Partial Failure + {{batch.status}} +

+
+ + + + +
+
+
+ +
+
+
+
+
Created: {{batch.createdTimestamp}}
+
Description: {{batch.comments}}
+
+
+
+
+
+ +
+
+

Changes

+
+
+ +
+ +
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Change TypeInput NameRecordset NameZone NameRecord TypeRecord DataTTLStatusAdditional Info
{{change.inputName}}{{change.recordName}}{{change.zoneName}}{{change.type}} +
    +
  • {{change.record.address}}
  • +
+
+
    +
  • {{change.record.cname}}
  • +
+
+
    +
  • {{change.record.ptrdname}}
  • +
+
+
    +
  • {{change.record.text}}
  • +
+
+
    +
  • Preference: {{change.record.preference}}
  • +
  • Exchange: {{change.record.exchange}}
  • +
+
{{change.ttl}} + {{change.status}} + {{change.status}} + {{change.status}} + + {{change.systemMessage}}
+
+
+
+
+
+
+} + +@plugins = {} + +@main(rootAccountName)("BatchChangeDetailController")("Batch Change")(content)(plugins) diff --git a/modules/portal/app/views/batchChanges/batchChangeNew.scala.html b/modules/portal/app/views/batchChanges/batchChangeNew.scala.html new file mode 100644 index 0000000000..4294c5eb4e --- /dev/null +++ b/modules/portal/app/views/batchChanges/batchChangeNew.scala.html @@ -0,0 +1,180 @@ +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) + +@content = { + +
+ + + + + + +

New Batch Change

+ + + + +
+
+
+ +
+
+
+
+
+ + +
+
+
+ + +
+
+
+

Changes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Change TypeRecord TypeInput NameTTLRecord Data
{{$index + 1}} + + + + + + +

+ Input name is required! +

+
+ +

+ TTL must be between 30 and 2147483647. +

+
+ + + + + +

+ Record data is required! + must be a valid IPv4 Address! +

+
+ +

+ Record data is required! + must be a valid IPv6 Address! +

+
+ +

+ Record data is required! + CNAME data must be absolute! +

+
+ +

+ Record data is required! +

+
+ +

+ Record data is required! +

+
+ + +

+ Preference is required! + Must be between 0 and 65535! + Must be between 0 and 65535! + +

+ +
+ + + +

+ Exchange data is required! + Exchange data must be absolute! +

+
+

+ {{batchError}} +

+
+ + Limit reached. Cannot add more than {{batchChangeLimit}} records per batch change. +
+ +
+
+
+
+
+
+} + +@plugins = {} + +@main(rootAccountName)("BatchChangeNewController")("New Batch Change")(content)(plugins) diff --git a/modules/portal/app/views/batchChanges/batchChanges.scala.html b/modules/portal/app/views/batchChanges/batchChanges.scala.html new file mode 100644 index 0000000000..ce04318eda --- /dev/null +++ b/modules/portal/app/views/batchChanges/batchChanges.scala.html @@ -0,0 +1,104 @@ +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) + +@content = { + +
+ + + + + + +

Batch Changes {{ getPageTitle() }}

+ + + +
+
+
+ +
+
+
+
+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
TimeBatch IDChange CountStatusDescriptionAction
{{batchChange.id}} + {{batchChange.status}} + Partial Failure + {{batchChange.status}} + + + View + +
+ + +
+ +
+ + +
+
+
+
+
+
+} + +@plugins = {} + +@main(rootAccountName)("BatchChangesController")("Batch Changes")(content)(plugins) diff --git a/modules/portal/app/views/groups/groupDetail.scala.html b/modules/portal/app/views/groups/groupDetail.scala.html new file mode 100644 index 0000000000..fd05f2a0bc --- /dev/null +++ b/modules/portal/app/views/groups/groupDetail.scala.html @@ -0,0 +1,114 @@ +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) + +@content = { + + +
+ +
+ + + + + +
+

Group {{membership.group.name}}

+
+ + + +
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+

Loading members...

+

You don't have any members yet.

+ + + + + + + + + + + + + + + + + + + +
User NameNameEmailGroup ManagerActions
{{member.userName}}{{([member.lastName, member.firstName] | filter: "" ).join(", ")}}{{member.email}} + + + +
+
+
+ + +
+
+ +
+ +
+
+ +} + +@plugins = { + + + +} + +@main(rootAccountName)("MembershipController")("Group")(content)(plugins) diff --git a/modules/portal/app/views/groups/groups.scala.html b/modules/portal/app/views/groups/groups.scala.html new file mode 100644 index 0000000000..1cf6784818 --- /dev/null +++ b/modules/portal/app/views/groups/groups.scala.html @@ -0,0 +1,192 @@ +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) + +@content = { + +
+ + + + + + +

Groups

+ + + +
+
+
+ +
+
+
+
+ + +
+
+
+ + +
+ +
+
+

Loading groups...

+

You don't have any groups yet.

+ + + + + + + + + + + + + + + + + +
Group NameEmailDescriptionActions
{{group.name}}{{group.email}}{{group.description}} +
+ + View + +
+
+
+
+ + +
+
+
+ + +
+ + + + + + + +
+ + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ + + Are you sure you want to delete this group?  + + + + +
+
+ +} + +@plugins = { + + + +} + +@main(rootAccountName)("GroupsController")("Groups")(content)(plugins) diff --git a/modules/portal/app/views/header.scala.html b/modules/portal/app/views/header.scala.html new file mode 100644 index 0000000000..a16d4c5291 --- /dev/null +++ b/modules/portal/app/views/header.scala.html @@ -0,0 +1,28 @@ +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any]) + +
+ +
diff --git a/modules/portal/app/views/login.scala.html b/modules/portal/app/views/login.scala.html new file mode 100644 index 0000000000..84bc0c58d5 --- /dev/null +++ b/modules/portal/app/views/login.scala.html @@ -0,0 +1,64 @@ +@import helper.CSRF +@(alertMessage: Option[String] = None)(implicit requestHeader: RequestHeader, customLinks: models.CustomLinks) + + + + + Please sign in using your Corporate Credentials + + + + + + + + + + + + + + + + diff --git a/modules/portal/app/views/main.scala.html b/modules/portal/app/views/main.scala.html new file mode 100644 index 0000000000..d4062fce92 --- /dev/null +++ b/modules/portal/app/views/main.scala.html @@ -0,0 +1,162 @@ +@import helper.CSRF +@(rootAccountName: String)(controller: String)(pageHeader: String)(pageContent: Html)(pagePlugins: Html)(implicit request: play.api.mvc.Request[Any], requestHeader: RequestHeader, customLinks: models.CustomLinks) + + + + + + @pageHeader | VinylDNS + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+
+ +
+ +
+
+ + @header(rootAccountName)(request) + @pageContent + +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @pagePlugins + + + diff --git a/modules/portal/app/views/zones/zoneDetail.scala.html b/modules/portal/app/views/zones/zoneDetail.scala.html new file mode 100644 index 0000000000..d00f977d70 --- /dev/null +++ b/modules/portal/app/views/zones/zoneDetail.scala.html @@ -0,0 +1,77 @@ +@(rootAccountName: String, zoneId: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) +@import zoneTabs._ + +@content = { + + {{ + @* Passes params from play to angular *@ + zoneId="@zoneId"; + "" + }} + +
+ + + + + + +
+

{{ zoneInfo.name }} + {{ zoneInfo.status }} +

+
+ + + +
+ +
+
+ +
+
+ + +
+ +
+ +
+ @manageRecords(request) +
+
+ @manageZone(request) +
+
+ @changeHistory(request) +
+
+
+ + + + +
+ + +
+ +} + +@plugins = { + + + + +} + +@main(rootAccountName)("RecordsController")("Zone")(content)(plugins) diff --git a/modules/portal/app/views/zones/zoneTabs/changeHistory.scala.html b/modules/portal/app/views/zones/zoneTabs/changeHistory.scala.html new file mode 100644 index 0000000000..4a36f227a2 --- /dev/null +++ b/modules/portal/app/views/zones/zoneTabs/changeHistory.scala.html @@ -0,0 +1,79 @@ +@(implicit request: play.api.mvc.Request[Any]) + + +
+
+

All Record Changes {{ getChangePageTitle() }}

+
+
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeRecordset NameRecordset TypeChange TypeUserStatusAdditional Info
{{change.created}}{{change.recordSet.name}}{{change.recordSet.type}}{{change.changeType}}{{change.userName}} + {{ change.status }} + + {{change.systemMessage}} + +
+ + +
+ +
+ + +
+ +
+ diff --git a/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html b/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html new file mode 100644 index 0000000000..f349ad3ba6 --- /dev/null +++ b/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html @@ -0,0 +1,304 @@ +@(implicit request: play.api.mvc.Request[Any]) + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TimeRecordset NameRecordset TypeChange TypeUserStatusAdditional Info
{{change.created}}{{change.recordSet.name}}{{change.recordSet.type}}{{change.changeType}}{{change.userName}} + {{ change.status }} + + {{change.systemMessage}} + +
+ +
+
+ + + + +
+
+

Records {{ getRecordPageTitle() }}

+
+ +
+
+
+ + + +
+ +
+
+
+ + + + +
+
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
NameTypeTTLRecord DataActions
+
+ {{record.name}} +
+
+ {{record.name}} +
+
{{record.type}}{{record.ttl}} + + + + + + + + + + + {{ record.cnameRecordData }} + +
    +
  • + Preference: {{ item.preference }} | Exchange: {{ item.exchange }} +
  • +
  • + more... +
  • +
  • + less... +
  • +
+
+ {{ record.ptrRecordData }} + {{ record.textRecordData }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mname: {{ record.soaMName }}
Rname: {{ record.soaRName }}
Serial: {{ record.soaSerial }}
Refresh: {{ record.soaRefresh }}
Retry: {{ record.soaRetry }}
Expire: {{ record.soaExpire }}
Minimum:  {{ record.soaMinimum }}
+
+ +
    +
  • + Priority: {{ item.priority }} | Weight: {{ item.weight }} | Port: {{ item.port }} + | Target: {{ item.target }} +
  • +
  • + more... +
  • +
  • + less... +
  • +
+
+ +
    +
  • + Algorithm: {{ sshfpAlgorithms[item.algorithm-1].name }} + | Type: {{ sshfpTypes[item.type-1].name }} + | Fingerprint: {{ item.fingerprint }} +
  • +
  • + more... +
  • +
  • + less... +
  • +
+
+
+
+ +
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + + + + diff --git a/modules/portal/app/views/zones/zoneTabs/manageZone.scala.html b/modules/portal/app/views/zones/zoneTabs/manageZone.scala.html new file mode 100644 index 0000000000..4b1b3b2a2a --- /dev/null +++ b/modules/portal/app/views/zones/zoneTabs/manageZone.scala.html @@ -0,0 +1,436 @@ +@(implicit request: play.api.mvc.Request[Any]) + +
+
+ +
+
+ + +
+
+
+
+

Zone Info

+
+
+
+
+ +
+ +
+ +
+
+

{{zoneId}}

+
+
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+
+ {{ updateZoneInfo.status }} +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+

{{updateZoneInfo.created}}

+
+
+ +
+ +
+

{{updateZoneInfo.updated}}

+
+
+ +
+ +
+

{{updateZoneInfo.latestSync}}

+
+
+ +
+
+
+
+
+
+ +
+

Connection Information (Optional)

+
+ +
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+
+ +
+ + If no connection is provided the default Vinyl connection will be used. + +
+
+ + +
+
+
+
+ +

Transfer Connection (Optional)

+
+ +
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+
+ +
+ + If no connection is provided the default Vinyl connection will be used. + +
+
+ + +
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+

+ Zone Access Rules +

+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
User/GroupAccess LevelRecord TypesRecord MaskDescriptionActions
+ {{rule.displayName}} + + {{rule.accessLevel}} + + All Types +
    +
  • + {{item}} +
  • +
+
+ {{rule.recordMask}} + + {{rule.description}} + + + + + +
+ +
+
+ + + +
+ + + +
+ diff --git a/modules/portal/app/views/zones/zones.scala.html b/modules/portal/app/views/zones/zones.scala.html new file mode 100644 index 0000000000..fb96605f24 --- /dev/null +++ b/modules/portal/app/views/zones/zones.scala.html @@ -0,0 +1,179 @@ +@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks) + +@content = { + +
+ + + + + + +
+

Zones {{ getZonePageTitle() }}

+
+ + + +
+
+
+ +
+
+
+
+ + +
+
+
+ + +
+ + +
+
+
+ + + + +
+
+
+ + +
+
+

Loading zones...

+

You have not connected to any zones.

+

No zones match the search criteria.

+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
NameEmailAdmin GroupStatusActions
+ + + +
+ + View + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ + + + + +} + +@plugins = { + + + +} + +@main(rootAccountName)("ZonesController")("Zones")(content)(plugins) diff --git a/modules/portal/conf/application-test.conf b/modules/portal/conf/application-test.conf new file mode 100644 index 0000000000..f1a197b9e4 --- /dev/null +++ b/modules/portal/conf/application-test.conf @@ -0,0 +1,62 @@ +play.http.secret.key = "changeme" +play.i18n.langs = [ "en" ] + +portal.dynamo_delay = 0 +portal.vinyldns.backend.url = "http://not.real.com" + +# configuration for the users and groups store +dynamo { + key = "akid goes here" + secret = "secret key goes here" + endpoint = "endpoint url goes here" + test_datastore = true +} + +users { + dummy = true + tablename = "userAccounts" + provisionedReadThroughput = 100 + provisionedWriteThroughput = 100 +} + +changelog { + dummy=true + tablename="usersAndGroupChanges" + provisionedReadThroughput=100 + provisionedWriteThroughput=100 +} + +LDAP { + user="test" + password="test" + domain="test" + + searchBase = [{organization = "someDomain", domainName = "DC=test,DC=test,DC=com"}, {organization = "anotherDomain", domainName = "DC=test,DC=com"}] + + context { + initialContextFactory = "com.sun.jndi.ldap.LdapCtxFactory" + securityAuthentication = "simple" + providerUrl = "ldaps://somedomain.com:9999" + } +} + +crypto { + type = "vinyldns.core.crypto.NoOpCrypto" +} + +links = [ + { + displayOnSidebar = true + displayOnLoginScreen = false + title = "test link sidebar" + href = "" + icon = "" + }, + { + displayOnSidebar = false + displayOnLoginScreen = true + title = "test link login" + href = "" + icon = "" + } +] diff --git a/modules/portal/conf/application.conf b/modules/portal/conf/application.conf new file mode 100644 index 0000000000..8012c96c57 --- /dev/null +++ b/modules/portal/conf/application.conf @@ -0,0 +1,114 @@ +# This is the main configuration file for the application. +# ~~~~~ + +# Secret key +# ~~~~~ +# The secret key is used to secure cryptographics functions. +# +# This must be changed for production, but we recommend not changing it in this file. +# +# See http://www.playframework.com/documentation/latest/ApplicationSecret for more details. +play.http.secret.key = "changeme" + +# The application languages +# ~~~~~ +play.i18n.langs = [ "en" ] + +# Router +# ~~~~~ +# Define the Router object to use for this application. +# This router will be looked up first when the application is starting up, +# so make sure this is the entry point. +# Furthermore, it's assumed your route file is named properly. +# So for an application router like `my.application.Router`, +# you may need to define a router file `conf/my.application.routes`. +# Default to Routes in the root package (and conf/routes) +# play.http.router = my.application.Routes + +# Database configuration +# ~~~~~ +# You can declare as many datasources as you want. +# By convention, the default datasource is named `default` +# +# db.default.driver=org.h2.Driver +# db.default.url="jdbc:h2:mem:play" +# db.default.username=sa +# db.default.password="" + +# Evolutions +# ~~~~~ +# You can disable evolutions if needed +# play.evolutions.enabled=false + +# You can disable evolutions for a specific datasource if necessary +# play.evolutions.db.default.enabled=false + +portal.dynamo_delay = 1100 +portal.vinyldns.backend.url = "http://localhost:9000" +portal.test_login = true + +# configuration for the users and groups store +dynamo { + key = "akid goes here" + secret = "secret key goes here" + endpoint = "http://127.0.0.1:19000" + test_datastore = false +} + +users { + dummy = false + tablename = "users" + provisionedReadThroughput = 100 + provisionedWriteThroughput = 100 +} + +changelog { + dummy = false + tablename = "usersAndGroupChanges" + provisionedReadThroughput = 100 + provisionedWriteThroughput = 100 +} + +LDAP { + user="test" + password="test" + domain="test" + + searchBase = [{organization = "someDomain", domainName = "DC=test,DC=test,DC=com"}, {organization = "anotherDomain", domainName = "DC=test,DC=com"}] + + context { + initialContextFactory = "com.sun.jndi.ldap.LdapCtxFactory" + securityAuthentication = "simple" + providerUrl = "ldaps://somedomain.com:9999" + } + +} + +play.filters.enabled += "play.filters.csrf.CSRFFilter" + +// Expire session after 10 hours +play.http.session.maxAge = 10h + +// session secure should be false in order to run properly locally, this is set properly on deployment +play.http.session.secure = false +play.http.session.httpOnly = true + +// use no-op by default +crypto { + type = "vinyldns.core.crypto.NoOpCrypto" +} + +http.port=9001 + +links = [ + { + displayOnSidebar = true + displayOnLoginScreen = true + title = "API Documentation" + href = "http://vinyldns.io" + icon = "fa fa-file-text-o" + } +] + +// Local.conf has files specific to your environment, for example your own LDAP settings +include "local.conf" diff --git a/modules/portal/conf/logback.xml b/modules/portal/conf/logback.xml new file mode 100644 index 0000000000..da64a76c77 --- /dev/null +++ b/modules/portal/conf/logback.xml @@ -0,0 +1,21 @@ + + + + + + + %d{"yyyy-MM-dd HH:mm:ss,SSS"} %coloredLevel - %logger - %message%n%xException + + + + + + + + + + + diff --git a/modules/portal/conf/routes b/modules/portal/conf/routes new file mode 100644 index 0000000000..a2da7af33d --- /dev/null +++ b/modules/portal/conf/routes @@ -0,0 +1,57 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# ~~~~ + +### Frontend pages - these are what the user can be navigate to ### +GET / @controllers.FrontendController.index +GET /index @controllers.FrontendController.index +GET /login @controllers.FrontendController.loginPage +GET /logout @controllers.FrontendController.logout + +GET /groups @controllers.FrontendController.viewAllGroups +GET /groups/:gid @controllers.FrontendController.viewGroup(gid: String) +GET /zones @controllers.FrontendController.viewAllZones +GET /zones/:zid @controllers.FrontendController.viewZone(zid: String) +GET /batchchanges @controllers.FrontendController.viewAllBatchChanges +GET /batchchanges/new @controllers.FrontendController.viewNewBatchChange +GET /batchchanges/:id @controllers.FrontendController.viewBatchChange(id: String) + +### Routes to process requests, get data ### +POST /login @controllers.VinylDNS.login +GET /download-creds-file/:fileName @controllers.VinylDNS.serveCredsFile(fileName) + +GET /api/zones @controllers.VinylDNS.getZones +GET /api/zones/:id @controllers.VinylDNS.getZone(id: String) +POST /api/zones @controllers.VinylDNS.addZone +PUT /api/zones/:id @controllers.VinylDNS.updateZone(id: String) +DELETE /api/zones/:id @controllers.VinylDNS.deleteZone(id: String) +POST /api/zones/:id/sync @controllers.VinylDNS.syncZone(id: String) + +GET /api/zones/:id/recordsets @controllers.VinylDNS.getRecordSets(id: String) +POST /api/zones/:id/recordsets @controllers.VinylDNS.addRecordSet(id: String) +DELETE /api/zones/:zid/recordsets/:rid @controllers.VinylDNS.deleteRecordSet(zid: String, rid:String) +PUT /api/zones/:zid/recordsets/:rid @controllers.VinylDNS.updateRecordSet(zid: String, rid:String) + +GET /api/zones/:id/recordsetchanges @controllers.VinylDNS.listRecordSetChanges(id: String) + +GET /api/groups @controllers.VinylDNS.getMyGroups +GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String) +POST /api/groups @controllers.VinylDNS.newGroup +PUT /api/groups/:gid @controllers.VinylDNS.updateGroup(gid: String) +DELETE /api/groups/:gid @controllers.VinylDNS.deleteGroup(gid: String) + +GET /api/groups/:gid/members @controllers.VinylDNS.getMemberList(gid: String) + +GET /api/users/currentuser @controllers.VinylDNS.getAuthenticatedUserData +GET /api/users/lookupuser/:uname @controllers.VinylDNS.getUserDataByUsername(uname: String) + +GET /api/batchchanges/:id @controllers.VinylDNS.getBatchChange(id: String) +GET /api/batchchanges @controllers.VinylDNS.listBatchChanges +POST /api/batchchanges @controllers.VinylDNS.newBatchChange + +# Map static resources from the /public folder to the /assets URL path +GET /public/*file controllers.Assets.versioned(path="/public", file: Asset) + +# Map static resources from the /public folder to the /assets URL path +GET /public/*file controllers.Assets.versioned(path="/public", file: Asset) +GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) diff --git a/modules/portal/karma.conf.js b/modules/portal/karma.conf.js new file mode 100644 index 0000000000..eb8a838a1c --- /dev/null +++ b/modules/portal/karma.conf.js @@ -0,0 +1,70 @@ +// Karma configuration +// http://karma-runner.github.io/0.10/config/configuration-file.html + +module.exports = function(config) { + config.set({ + // base path, that will be used to resolve files and exclude + basePath: 'public/', + + // testing framework to use (jasmine/mocha/qunit/...) + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: [ + 'javascripts/jquery.min.js', + 'javascripts/angular.min.js', + 'javascripts/*.min.js', + 'test_frameworks/*.js', + 'lib/services/**/*.js', + 'lib/controllers/**/*.js', + 'lib/directives/**/*.js', + 'lib/batch-change/batch-change.module.js', + 'lib/*.js', + 'lib/batch-change/*.js', + //fixtures + {pattern: 'mocks/*.json', watched: true, served: true, included: false} + ], + + // list of files / patterns to exclude + exclude: [], + + // web server port + port: 8080, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + logLevel: config.LOG_INFO, + + plugins: [ + 'karma-jasmine', + 'karma-phantomjs-launcher', + 'karma-mocha-reporter' + ], + + // reporter types: + // - dots + // - progress (default) + // - spec (karma-spec-reporter) + // - junit + // - growl + // - coverage + reporters: ['mocha'], + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: ['PhantomJS'], + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: true + }); +}; diff --git a/modules/portal/package.json b/modules/portal/package.json new file mode 100644 index 0000000000..c920e35d15 --- /dev/null +++ b/modules/portal/package.json @@ -0,0 +1,47 @@ +{ + "name": "vinyl-portal", + "version": "1.0.0", + "description": "Self-service DNS offering", + "main": "index.js", + "directories": { + "test": "test" + }, + "dependencies": { + "@uirouter/angularjs": "0.3.2", + "angular": "1.5.0", + "angular-animate": "1.6.1", + "angular-ui-bootstrap": "2.5.6", + "bootstrap": "3.3.7", + "font-awesome": "4.7.0", + "gentelella": "1.4.0", + "grunt": "^1.0.3", + "grunt-angular-templates": "^1.1.0", + "grunt-contrib-clean": "^1.0.0", + "grunt-contrib-copy": "^1.0.0", + "grunt-copy": "^0.1.0", + "grunt-injector": "^1.1.0" + }, + "devDependencies": { + "angular-mocks": "1.6.1", + "grunt-karma": "^2.0.0", + "grunt-mocha-phantomjs": "^3.0.0", + "jasmine-core": "^2.99.1", + "jasmine-jquery": "2.1.1", + "jquery": "^3.3.1", + "karma": "^2.0.4", + "karma-chrome-launcher": "^1.0.1", + "karma-jasmine": "^1.0.2", + "karma-mocha-reporter": "^2.0.3", + "karma-phantomjs-launcher": "^1.0.0", + "karma-spec-reporter": "^0.0.26", + "phantomjs-prebuilt": "^2.1.16" + }, + "scripts": { + "test": "unit" + }, + "repository": { + "type": "git", + "url": "https://github.com/vinyldns/vinyldns.git" + }, + "license": "Apache-2.0" +} diff --git a/modules/portal/prepare-portal.sh b/modules/portal/prepare-portal.sh new file mode 100755 index 0000000000..4068ff8166 --- /dev/null +++ b/modules/portal/prepare-portal.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +DIR=$( cd $(dirname $0) ; pwd -P ) + +cd $DIR + +npm install + +npm install grunt -g +grunt default +$DIR/../../bin/add-license-headers.sh -d=$DIR/public/lib -f=js + +cd - diff --git a/modules/portal/public/app.js b/modules/portal/public/app.js new file mode 100644 index 0000000000..a4f803b18b --- /dev/null +++ b/modules/portal/public/app.js @@ -0,0 +1,42 @@ +angular.module('vinyldns', [ + 'services.module', + 'controllers.module', + 'directives.module', + 'batch-change' +]) + .config(function ($httpProvider, $animateProvider, $logProvider) { + $httpProvider + .defaults.transformResponse.push(function (responseData) { + convertDateStringsToDates(responseData); + return responseData; + }); + $animateProvider + .classNameFilter(/toshow/); + //turning off $log + $logProvider.debugEnabled(false); + }) + .controller('AppController', function ($scope) { + }); + +var regexIso8601 = /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/; + +function convertDateStringsToDates(input) { + if (typeof input !== "object") return input; + + for (var key in input) { + if (!input.hasOwnProperty(key)) continue; + + var value = input[key]; + var match; + if (typeof value === "string" && (match = value.match(regexIso8601))) { + var milliseconds = Date.parse(match[0]); + if (!isNaN(milliseconds)) { + value = new Date(milliseconds); + input[key] = value.toString(); + input[key] = input[key].substring(0, 24); + } + } else if (typeof value === "object") { + convertDateStringsToDates(value); + } + } +} diff --git a/modules/portal/public/css/theme-overrides.css b/modules/portal/public/css/theme-overrides.css new file mode 100644 index 0000000000..18bc755c2f --- /dev/null +++ b/modules/portal/public/css/theme-overrides.css @@ -0,0 +1,148 @@ +body { + color: #000; +} + +a { + color: #0066CC; +} + +.btn-primary, .btn-primary:disabled:hover { + background-color: #000000; + border-color: #000000; + color: #fff; +} + +.btn-primary:hover { + background-color: #262626; + border-color: #262626; +} + +.btn-info, .btn-info:disabled:hover { + background-color: #fff; + border-color: #1bcfe1; + color: #1bcfe1; +} + +.btn-info:hover { + background-color: #fff; + border-color: #18baca; + color: #18baca; +} + +.btn-danger, .btn-danger:disabled:hover { + background-color: #fff; + border-color: #E04B4A; + color: #E04B4A; +} + +.btn-danger:hover { + background-color: #fff; + border-color: #bd2120; + color: #bd2120; +} + +.btn-success, .btn-success:disabled:hover { + background-color: #1bcfe1; + border-color: #19bfcf; +} + +.btn-success:hover { + background-color: #19bfcf; + border-color: #17afbe; +} + +.dataTables_paginate .pagination { + margin: 0px; +} + +.nav-sm .nav.side-menu li.active-sm { + border-right: 5px solid #1bcfe1; +} + +.nav-sm span.fa { + display: inline; +} + +.nav.side-menu>li.current-page, .nav.side-menu>li.active { + border-right: 5px solid #1b1e24; +} + +.nav.navbar-nav>li>a { + color: #E7E7E7!important; +} + +.top_nav .nav>li>a:focus, .top_nav .nav>li>a:hover, +.top_nav .nav .open>a, .top_nav .nav .open>a:focus, +.top_nav .nav .open>a:hover { + background: #1bcfe1; +} + +.top_nav .nav>li>a:hover { + background: #1bcfe1; +} + +.toggle a { + color: #E7E7E7; +} + +.x_content { + padding: 10px 17px; +} + +.x_panel { + padding: 0px; +} + +/* TABS */ +ul.bar_tabs { + background: none; +} + +ul.bar_tabs>li.active { + border: 1px solid #E6E9ED; + margin-top: -15px; + border-bottom: none; +} + +ul.bar_tabs>li a { + background: #F5F5F5; + color: #555555; +} + +/* FORMS */ +.form-group .btn { + margin-bottom: 0; +} + +.form-group.panel-footer { + margin-bottom: 0; +} + +/* MODALS */ +.modal-body p { + margin: 0 20px; +} + +/* GROUP MAIN TABLE */ +.group-members>tbody>tr>td { + vertical-align: middle; + padding: 8px; +} + +.group-members button.btn.btn-danger { + margin-bottom: 0; +} + +/* PAGINATION */ +.pagination>li>a { + color: #333; + border: 1px solid #ccc; + background-color: #FFF!important; + cursor: pointer; +} + +.pagination>li>a:hover { + color: #333; + background-color: #e6e6e6!important; + border: 1px solid #adadad!important; +} diff --git a/modules/portal/public/css/vinyldns.css b/modules/portal/public/css/vinyldns.css new file mode 100644 index 0000000000..d508afffa1 --- /dev/null +++ b/modules/portal/public/css/vinyldns.css @@ -0,0 +1,380 @@ +[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + display: none !important; +} + +.table-cell-list { + list-style-position: inside; + padding-left:0; +} +.login-container .login-box .login-footer .login-links a { + color: #428bca; +} + +.vinyldns-login-title { + text-align: center; +} + +.form-horizontal .label-top-left { + text-align:left; + width: 100%; + padding-left:5px; + padding-bottom:5px; +} + +a.action-link { + text-decoration:none; +} + +.form-control.record-edit { + background: #F9F9F9; + color:#555; +} + +.form-control.acl-edit { + background: #F9F9F9; + color:#555; +} + +.fa.inline-icon { + padding: 1px 2px 1px 2px; + font-size: 15px; + vertical-align: middle; +} + +.modal { + text-align: center; + padding: 0!important; +} + +.modal:before { + content: ''; + display: inline-block; + height: 100%; + vertical-align: middle; + margin-right: -4px; +} + +.modal-dialog { + display: inline-block; + text-align: left; + vertical-align: middle; +} + +.page-container .page-content .content-frame .content-frame-body { + /* joli admin had this at 300px to make a left data panel possible. We were using the left panel to add recordsets, + but decided this should be in a modal of its own instead. We are now making the recordset list full sized */ + margin-left: 0px; +} + +.x-navigation > li > a.logo-header { + padding: 3px 50px 3px 20px; + border-bottom: none; + background-color: #f5f5f5; +} + +.x-navigation > li.logo > a.logo-header > img { + width: 100%; +} + +.alert-box { + position: relative; + left: -50%; + padding: 10px 10px 12px 10px; + vertical-align: middle; + text-align: center; +} + +.alert-wrapper { + position: fixed; + top: 3px; + left: 50%; + width: 40%; + z-index: 100; +} + +.dns-connection-form { + padding-top: 10px; + margin: 0 20px; +} + +.dns-connection-form hr { + margin-top: 0px; +} + +.table-col-20 { + width: 20%; +} + +.table-col-50 { + width: 50%; +} + +.header-drop-down { + border: 0px +} + +.btn-left-round { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} + +.form-control.zone-edit { + background: #F9F9F9; + color:#555; +} + +.force-cursor { + cursor: pointer; +} + +.vinyldns-login { + background: #1b1e24!important; +} + +.vinyldns-login-box { + width: 100%; + float: left; + background: rgba(255, 255, 255, 0.1); + padding: 20px; + -moz-border-radius: 0px; + -webkit-border-radius: 0px; + border-radius: 0px; +} + +.vinyldns-login-title { + color: #FFFFFF; + text-shadow: none; +} + +.vinyldns-login-btn { + background: #1bcfe1; + color: #FFFFFF; +} + +.vinyldns-login-btn:hover { + background: #16a3b1; + color: #FFFFFF; +} + +.vinyldns-login-footer { + color: #FFFFFF; +} + +.vinyldns-login-footer a { + color: #1bcfe1; +} + +.vinyldns-btn-dark { + background: #000000; + color: #FFFFFF; +} + +.vinyldns-btn-dark:hover { + color: #FFFFFF; +} + +.vinyldns-panel-top { + display: flex; + justify-content: space-between; +} + +/* V3 MAIN*/ + +body { + background: #1b1e24; + font-family: "Helvetica Neue", Roboto, Arial, "Droid Sans", sans-serif; + font-size: 13px; + font-weight: 400; +} + +.left_col { + background: #1b1e24; +} + +.nav.side-menu>li.active>a { + text-shadow: rgba(0, 0, 0, 0.25) 0 -1px 0; + background: #1bcfe1; + box-shadow: rgba(0, 0, 0, 0.25) 0 1px 0, inset rgba(255, 255, 255, 0.16) 0 1px 0 +} + +.nav.side-menu>li.active { + border-right: 5px solid #1bcfe1; +} + +.left_col .vinyldns-logo { + height: 100%; + display: block; + padding: 5px; +} + +.nav_title { + width: 230px; + float: left; + background: #1b1e24; + border-radius: 0; + height: 57px +} + +.vinyldns-nav-title { + background: #FFFFFF; + padding-left: 5px; + border-bottom: 3px solid #1b1e24; +} + +.vinyldns-side-menu { + margin-top: 0!important; +} + +.nav_menu { + float: left; + background: #1b1e24; + border-bottom: 1px solid #D9DEE4; + margin-bottom: 10px; + width: 100%; + position: relative +} + +.nav-sm .container.body .right_col { + padding: 0; +} + +.nav-md .container.body .right_col { + padding: 0; +} + +.breadcrumb { + background: #E8E8E8; +} + +.page-title { + padding: 10px 20px 0; +} + +.page-content-wrap { + padding: 10px 20px 0; + min-height: 100vh; +} + +.vinyldns-logo-small { + display: none!important; +} + +.nav-sm .vinyldns-logo-small { + display: block!important; +} + + +/* GROUP MANAGER SWITCH */ +label.switch { + margin-bottom: 0; +} +.switch { + position: relative; + height: 30px; +} + +.switch-checkbox { + position: absolute; + opacity: 0; + filter: alpha(opacity=0); +} + +.switch input:checked + .slider:after { + left: 30px; +} + +.switch input:checked + .slider { + background-color: #95b75d; +} + +.switch input:disabled + .slider { + opacity: 0.5; + cursor: not-allowed; +} + +.slider { + cursor: pointer; + position: relative; + width: 60px; + height: 30px; + background-color: #E04B4A; + border: 1px solid #E5E5E5; + display: inline-block; + transition: all 200ms ease; + border-color: rgba(0, 0, 0, 0.1); + left: 0px; + border-radius: 20px; +} + +.slider:after { + content: ""; + position: absolute; + background-color: #FFFFFF; + width: 27px; + top: 1px; + bottom: 1px; + left: 1px; + box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.35); + border-radius: 20px; + transition: all 200ms ease; +} + +.panel-tabs { + padding-top: 5px; +} + +.update-zone-msg { + display: flex; + align-items: center; + justify-content: flex-end; +} + +@media(min-width: 991px) { + #menu_mobile { + display: none; + } + .nav-md > .container > .main_container > .top_nav > .nav_menu > nav > .toggle > #menu_indent { + display: none; + } + + .nav-sm > .container > .main_container > .top_nav > .nav_menu > nav > .toggle > #menu_dedent { + display: none; + } +} + +@media(max-width: 990px) { + #menu_indent { + display: none; + } + #menu_dedent { + display: none; + } +} + +.table-form-group > .btn { + margin-bottom: 0; +} + +.createBatchChangeForm.ng-submitted input.ng-invalid { + border-color: #a94442; +} + +.createBatchChangeForm .form-control:focus { + border-color: #1bcfe1; +} + +.batch-change-error-help { + color: #a94442; +} + +.ng-hide { + display: none; +} + +.changeError { + background: rgba(255, 0, 0, 0.32); +} + +.batch-label { + color: #73879C; +} diff --git a/modules/portal/public/images/email.svg b/modules/portal/public/images/email.svg new file mode 100644 index 0000000000..1a444a26d4 --- /dev/null +++ b/modules/portal/public/images/email.svg @@ -0,0 +1,15 @@ + + + + Rectangle 166 + Triangle 1 + Created with Sketch. + + + + + + + + + + diff --git a/modules/portal/public/images/favicon.ico b/modules/portal/public/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..77b54ff6679d1efcf5f44edb9eea94d15b631b1e GIT binary patch literal 15086 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

fff4Ks=k>He?Z}H&UtzRN+s+41 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

TTH}G!N;;{3={W?d-uT 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

fff4Ks=k>He?Z}H&UtzRN+s+41 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

TTH}G!N;;{3={W?d-uT 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

fff4Ks=k>He?Z}H&UtzRN+s+41 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

TTH}G!N;;{3={W?d-uT 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' + + '' + + '' + ); + this.scope.change = { fqdn: null }; + $compile(element)(this.scope); + form = this.scope.form; + })); + + it('passes with at least one dot', function(){ + form.cname.$setViewValue('test.com'); + this.scope.$digest(); + expect(this.scope.change.cname).toEqual('test.com'); + expect(form.cname.$valid).toBe(true); + }); + + it('passes with trailing dot', function(){ + form.cname.$setViewValue('test.'); + this.scope.$digest(); + expect(this.scope.change.cname).toEqual('test.'); + expect(form.cname.$valid).toBe(true); + }); + + it('fails without any dots', function(){ + form.cname.$setViewValue('invalidfqdn'); + this.scope.$digest(); + expect(this.scope.change.cname).toBeUndefined(); + expect(form.cname.$valid).toBe(false); + }); + }); + + describe('Directive: IPv4 validation', function(){ + var form; + beforeEach(inject(function($compile, $rootScope) { + this.rootScope = $rootScope; + this.scope = $rootScope.$new(); + var element = angular.element( + '

' + + '' + + '' + ); + this.scope.change = { address: null }; + $compile(element)(this.scope); + form = this.scope.form; + })); + + it('passes with correct IPv4 format', function(){ + form.address.$setViewValue('1.1.1.1'); + this.scope.$digest(); + expect(this.scope.change.address).toEqual('1.1.1.1'); + expect(form.address.$valid).toBe(true); + }); + + it('fails with incorrect IPv4 format', function(){ + form.address.$setViewValue('bad.ipv4'); + this.scope.$digest(); + expect(this.scope.change.address).toBeUndefined(); + expect(form.address.$valid).toBe(false); + }); + }); + + describe('Directive: IPv6 validation', function(){ + var form; + beforeEach(inject(function($compile, $rootScope) { + this.rootScope = $rootScope; + this.scope = $rootScope.$new(); + var element = angular.element( + '
' + + '' + + '
' + ); + this.scope.change = { address: null }; + $compile(element)(this.scope); + form = this.scope.form; + })); + + it('passes with correct IPv6 format', function(){ + form.address.$setViewValue('fd69:27cc:fe91::60'); + this.scope.$digest(); + expect(this.scope.change.address).toEqual('fd69:27cc:fe91::60'); + expect(form.address.$valid).toBe(true); + }); + + it('fails with incorrect IPv6 format', function(){ + form.address.$setViewValue('bad.ipv6'); + this.scope.$digest(); + expect(this.scope.change.address).toBeUndefined(); + expect(form.address.$valid).toBe(false); + }); + }); +}); diff --git a/modules/portal/public/lib/batch-change/batch-changes.controller.js b/modules/portal/public/lib/batch-change/batch-changes.controller.js new file mode 100644 index 0000000000..3c238ce9dd --- /dev/null +++ b/modules/portal/public/lib/batch-change/batch-changes.controller.js @@ -0,0 +1,106 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function() { + 'use strict'; + + angular.module('batch-change') + .controller('BatchChangesController', function($scope, $timeout, batchChangeService, pagingService, utilityService){ + $scope.batchChanges = []; + // Set default params: empty start from and 100 max items + var batchChangePaging = pagingService.getNewPagingParams(100); + + $scope.getBatchChanges = function(maxItems, startFrom) { + function success(response) { + return response; + } + + return batchChangeService + .getBatchChanges(maxItems, startFrom) + .then(success) + .catch(function(error) { + handleError(error, 'batchChangesService::getBatchChanges-failure'); + }); + }; + + function handleError(error, type) { + var alert = utilityService.failure(error, type); + $scope.alerts.push(alert); + } + + $scope.refreshBatchChanges = function() { + batchChangePaging = pagingService.resetPaging(batchChangePaging); + + function success(response) { + batchChangePaging.next = response.data.nextId; + $scope.batchChanges = response.data.batchChanges; + } + + return batchChangeService + .getBatchChanges(batchChangePaging.maxItems, undefined) + .then(success) + .catch(function (error){ + handleError(error, 'batchChangesService::getBatchChanges-failure'); + }); + }; + + // Previous page button enabled? + $scope.prevPageEnabled = function() { + return pagingService.prevPageEnabled(batchChangePaging); + }; + + // Next page button enabled? + $scope.nextPageEnabled = function() { + return pagingService.nextPageEnabled(batchChangePaging); + }; + + // Get page number for display + $scope.getPageTitle = function() { + return pagingService.getPanelTitle(batchChangePaging); + }; + + $scope.prevPage = function() { + var startFrom = pagingService.getPrevStartFrom(batchChangePaging); + return $scope + .getBatchChanges(batchChangePaging.maxItems, startFrom) + .then(function(response) { + batchChangePaging = pagingService.prevPageUpdate(response.data.nextId, batchChangePaging); + $scope.batchChanges = response.data.batchChanges; + }) + .catch(function (error){ + handleError(error, 'batchChangesService::getBatchChanges-failure'); + }); + }; + + $scope.nextPage = function() { + return $scope + .getBatchChanges(batchChangePaging.maxItems, batchChangePaging.next) + .then(function(response) { + var batchChanges = response.data.batchChanges; + batchChangePaging = pagingService.nextPageUpdate(batchChanges, response.data.nextId, batchChangePaging); + + if(batchChanges.length > 0 ){ + $scope.batchChanges = batchChanges; + } + }) + .catch(function (error){ + handleError(error, 'batchChangesService::getBatchChanges-failure'); + }); + }; + + $timeout($scope.refreshBatchChanges, 0); + }); +})(); diff --git a/modules/portal/public/lib/constants.js b/modules/portal/public/lib/constants.js new file mode 100644 index 0000000000..e68ede9bf3 --- /dev/null +++ b/modules/portal/public/lib/constants.js @@ -0,0 +1,21 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular + .module('batch-change') + .constant('jsConfig', { + batchChangeLimit: 20 + }); diff --git a/modules/portal/public/lib/controllers/controller.groups.js b/modules/portal/public/lib/controllers/controller.groups.js new file mode 100644 index 0000000000..d64d739af5 --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.groups.js @@ -0,0 +1,178 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('controller.groups', []).controller('GroupsController', function ($scope, $log, $location, groupsService, profileService, utilityService) { + //registering bootstrap modal close event to refresh data after create group action + angular.element('#modal_new_group').one('hide.bs.modal', function () { + $scope.closeModal(); + }); + + $scope.groups = { items: [] }; + $scope.groupsLoaded = false; + $scope.alerts = []; + + function handleError(error, type) { + var alert = utilityService.failure(error, type); + $scope.alerts.push(alert); + $scope.processing = false; + } + + //views + //shared modal + var modalDialog; + + $scope.openModal = function(evt){ + $scope.currentGroup = {}; + void(evt && evt.preventDefault()); + if(!modalDialog){ + modalDialog = angular.element('#modal_new_group').modal(); + } + modalDialog.modal('show'); + }; + + $scope.closeModal = function(evt){ + void(evt && evt.preventDefault()); + if(!modalDialog){ + modalDialog = angular.element('#modal_new_group').modal(); + } + modalDialog.modal('hide'); + return true; + }; + + $scope.createGroup = function (name, email, description) { + //prevent user executing service call multiple times + //if true prevent, if false allow for execution of rest of code + //ng-href='/groups' + $log.log('createGroup::called', $scope.data); + + if ($scope.processing) { + $log.log('createGroup::processing is true; exiting'); + return; + } + //flag to prevent multiple clicks until previous promise has resolved. + $scope.processing = true; + + //data from user form values + var payload = + { + 'name': name, + 'email': email, + 'description': description, + 'members': [{ id: $scope.profile.id }], + 'admins': [{ id: $scope.profile.id }] + }; + + //create group success callback + function success(response) { + var alert = utilityService.success('Successfully Created Group: ' + name, response, 'createGroup::createGroup successful'); + $scope.alerts.push(alert); + $scope.closeModal(); + $scope.reset(); + $scope.refresh(); + return response.data; + } + + return groupsService.createGroup(payload) + .then(success) + .catch(function (error){ + handleError(error, 'groupsService::createGroup-failure'); + }); + }; + + $scope.refresh = function () { + //get users groups + function success(result) { + $log.log('getMyGroups:refresh-success', result); + //update groups + $scope.groups.items = result.groups; + $scope.groupsLoaded = true; + return result; + } + return getMyGroups() + .then(success) + .catch(function (error) { + handleError(error, 'getMyGroups::refresh-failure'); + }); + }; + + $scope.reset = function () { + //reset processing flag + $scope.processing = false; + //fields with ng-patterns need to be set to null first then cleared + $scope.createGroupForm.$commitViewValue(); + //this resets $scope.currentGroup object to empty object; + angular.copy({}, $scope.currentGroup); + //reset all validations & error messages to pre-form submission state + $scope.createGroupForm.$setUntouched(); + $scope.createGroupForm.$setPristine(); + + return true; + }; + + function getMyGroups() { + function success(response) { + $log.log('groupsService::getMyGroups-success'); + return response.data; + } + return groupsService + .getMyGroups() + .then(success) + .catch(function (error){ + handleError(error, 'groupsService::getMyGroups-failure'); + }); + } + + $scope.confirmDeleteGroup = function (groupInfo) { + $scope.currentGroup = groupInfo; + $("#delete_group_modal").modal("show"); + }; + + $scope.submitDeleteGroup = function () { + function success (response){ + $("#delete_group_modal").modal("hide"); + $scope.refresh(); + var alert = utilityService.success('Removed Group: ' + $scope.currentGroup.name, response, 'groupsService::deleteGroup successful'); + $scope.alerts.push(alert); + } + groupsService.deleteGroups($scope.currentGroup.id) + .then(success) + .catch(function (error){ + handleError(error, 'groupsService::deleteGroup-failure'); + }); + }; + + function profileSuccess(results) { + //if data is provided + if (results.data) { + //update user profile data + //make user profile available to page + $scope.profile = results.data; + $log.log($scope.profile); + //load data in grid + $scope.refresh(); + } + } + + function profileFailure(results) { + $scope.profile = $scope.profile || {}; + } + + //get user data on groups view load + profileService.getAuthenticatedUserData() + .then(profileSuccess, profileFailure) + .catch(profileFailure); + +}); diff --git a/modules/portal/public/lib/controllers/controller.groups.spec.js b/modules/portal/public/lib/controllers/controller.groups.spec.js new file mode 100644 index 0000000000..1be9f3a98a --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.groups.spec.js @@ -0,0 +1,114 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Controller: GroupsController', function () { + beforeEach(function () { + module('ngMock'), + module('service.groups'), + module('service.profile'), + module('service.utility') + module('controller.groups') + }); + beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, utilityService) { + this.scope = $rootScope.$new(); + this.groupsService = groupsService; + this.utilityService = utilityService; + this.q = $q; + + profileService.getAuthenticatedUserData = function() { + return $q.when('data') + }; + this.groupsService.getMyGroups = function() { + return $q.when({ + data: { + group: 'mock group' + } + }) + }; + this.controller = $controller('GroupsController', {'$scope': this.scope}); + + this.mockSuccessAlert = 'success'; + this.mockFailureAlert = 'failure'; + this.utilitySuccess = spyOn(this.utilityService, 'success') + .and.stub() + .and.returnValue(this.mockSuccessAlert); + this.utilityFailure = spyOn(this.utilityService, 'failure') + .and.stub() + .and.returnValue(this.mockFailureAlert); + })); + + it('test that we properly set group data when running refresh', function(){ + this.scope.groups = {}; + var response = { + data: { + groups: "all my groups" + } + }; + var getMyGroups = spyOn(this.groupsService, 'getMyGroups') + .and.stub() + .and.returnValue(this.q.when(response)); + + this.scope.refresh(); + this.scope.$digest(); + + expect(getMyGroups.calls.count()).toBe(1); + expect(this.scope.groups.items).toBe("all my groups"); + }); + + it('createGroup correctly calls utilityService when passing createGroup', function() { + this.scope.profile = { + id: 'profile_ID' + }; + this.scope.data = { + name: 'NewGroup' + }; + var reset = spyOn(this.scope, 'reset') + .and.stub(); + var refresh = spyOn(this.scope,'refresh') + .and.stub(); + var groupsService = spyOn(this.groupsService, 'createGroup') + .and.stub() + .and.returnValue(this.q.when('success')); + + this.scope.createGroup(); + this.scope.$digest(); + + expect(reset.calls.count()).toBe(1); + expect(refresh.calls.count()).toBe(1); + expect(groupsService.calls.count()).toBe(1); + expect(this.utilitySuccess.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockSuccessAlert]); + }); + + it('createGroup correctly calls utilityService when failing createGroup', function() { + this.scope.profile = { + id: 'profile_ID' + }; + this.scope.data = { + name: 'NewGroup' + }; + var createGroup = spyOn(this.groupsService, 'createGroup') + .and.stub() + .and.returnValue(this.q.reject('Group')); + + this.scope.createGroup(); + this.scope.$digest(); + + expect(createGroup.calls.count()).toBe(1); + expect(this.utilityFailure.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockFailureAlert]); + }); +}); diff --git a/modules/portal/public/lib/controllers/controller.manageZones.js b/modules/portal/public/lib/controllers/controller.manageZones.js new file mode 100644 index 0000000000..ac4474f9d3 --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.manageZones.js @@ -0,0 +1,277 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('controller.manageZones', []) + .controller('ManageZonesController', function ($scope, $timeout, $log, recordsService, zonesService, groupsService, + profileService, utilityService) { + + groupsService.getMyGroupsStored() + .then(function (results) { + $scope.myGroups = results.groups; + }) + .catch(function (error){ + handleError(error, 'getMyGroup:get-groups-failure'); + }); + + /** + * Zone scope data initial setup + */ + + $scope.alerts = []; + $scope.zoneInfo = {}; + $scope.updateZoneInfo = {}; + $scope.manageZoneState = { + UPDATE: 0, + CONFIRM_UPDATE: 1 + }; + + /** + * Acl scope data initial setup + */ + + $scope.aclTypes = ['User', 'Group']; + $scope.aclAccessLevels = ['Read', 'Write', 'Delete', 'No Access']; + $scope.currentAclRule = {}; + $scope.currentAclRuleIndex = {}; + $scope.aclRules = []; + $scope.aclModalState = { + CREATE: 0, + UPDATE: 1, + CONFIRM_UPDATE: 2, + CONFIRM_DELETE: 3 + }; + $scope.aclModalParams = { + readOnly: { + class: '', + readOnly: true + }, + editable: { + class: 'acl-edit', + readOnly: false + } + }; + + /** + * Zone modal control functions + */ + + $scope.clickUpdateZone = function() { + $scope.currentManageZoneState = $scope.manageZoneState.CONFIRM_UPDATE; + }; + + $scope.cancelUpdateZone = function() { + $scope.currentManageZoneState = $scope.manageZoneState.UPDATE; + }; + + /** + * Acl modal control functions + */ + + $scope.clickCreateAclRule = function() { + $scope.currentAclRule = { + priority: 'User', + accessLevel: 'Read' + }; + $scope.aclModal = { + action: $scope.aclModalState.CREATE, + title: 'Create ACL Rule', + details: $scope.aclModalParams.editable + }; + $scope.addAclRuleForm.$setPristine(); + $('#acl_modal').modal('show'); + }; + + $scope.clickDeleteAclRule = function(index) { + $scope.currentAclRuleIndex = index; + $scope.currentAclRule = $scope.aclRules[index]; + $scope.aclModal = { + action: $scope.aclModalState.CONFIRM_DELETE, + title: 'Delete ACL Rule', + details: $scope.aclModalParams.readOnly + }; + $('#acl_modal').modal('show'); + }; + + $scope.clickUpdateAclRule = function(index) { + $scope.currentAclRuleIndex = index; + $scope.currentAclRule = angular.copy($scope.aclRules[index]); + $scope.aclModal = { + action: $scope.aclModalState.UPDATE, + title: 'Update ACL Rule', + details: $scope.aclModalParams.editable + }; + $('#acl_modal').modal('show'); + }; + + $scope.confirmUpdateAclRule = function (bool) { + if (bool) { + $scope.aclModal.action = $scope.aclModalState.CONFIRM_UPDATE; + } else { + $scope.aclModal.action = $scope.aclModalState.UPDATE; + } + }; + + $scope.closeAclModal = function() { + $scope.addAclRuleForm.$setPristine(); + }; + + $scope.clearForm = function() { + $scope.currentAclRule = { + priority: 'User', + accessLevel: 'Read' + }; + $scope.addAclRuleForm.$setPristine(); + }; + + /** + * Zone form submission functions + */ + + $scope.submitUpdateZone = function () { + var zone = angular.copy($scope.updateZoneInfo); + zone = zonesService.normalizeZoneDates(zone); + zone = zonesService.setConnectionKeys(zone); + $scope.currentManageZoneState = $scope.manageZoneState.UPDATE; + $scope.updateZone(zone, 'Zone Update'); + }; + + $scope.submitDeleteAclRule = function() { + var newZone = angular.copy($scope.zoneInfo); + newZone = zonesService.normalizeZoneDates(newZone); + newZone.acl.rules.splice($scope.currentAclRuleIndex, 1); + $scope.updateZone(newZone, 'ACL Rule Delete'); + $("#acl_modal").modal('hide'); + }; + + $scope.submitAclRule = function(type) { + if ($scope.addAclRuleForm.$valid) { + $("#acl_modal").modal('hide'); + if ($scope.currentAclRule.priority == 'User') { + profileService.getUserDataByUsername($scope.currentAclRule.userName) + .then(function (profile) { + $log.log('profileService::getUserDataByUsername-success'); + $scope.currentAclRule.userId = profile.data.id; + $scope.postUserLookup(type); + }) + .catch(function (error){ + handleError(error, 'profileService::getUserDataByUsername-failure'); + }); + } else { + $scope.postUserLookup(type); + } + } + }; + + $scope.postUserLookup = function(type) { + var newRule = zonesService.toVinylAclRule($scope.currentAclRule); + var newZone = angular.copy($scope.zoneInfo); + newZone = zonesService.normalizeZoneDates(newZone); + if (type == 'Update') { + newZone.acl.rules[$scope.currentAclRuleIndex] = newRule; + $scope.updateZone(newZone, 'ACL Rule Update'); + } else if (type == 'Create') { + newZone.acl.rules.push(newRule); + $scope.updateZone(newZone, 'ACL Rule Create'); + } + $scope.addAclRuleForm.$setPristine(); + }; + + /** + * Form helpers + */ + + $scope.objectsDiffer = function(left, right) { + var l = $scope.normalizeZone(left); + var r = $scope.normalizeZone(right); + return !angular.equals(l, r); + }; + + $scope.normalizeZone = function(zone) { + var vinyldnsZone = angular.copy(zone); + delete vinyldnsZone.adminGroupName; + delete vinyldnsZone.hiddenKey; + delete vinyldnsZone.hiddenTransferKey; + return vinyldnsZone; + }; + + $scope.clearUpdateConnection = function() { + delete $scope.updateZoneInfo.connection; + $scope.updateZoneInfo.hiddenKey = ''; + }; + + $scope.clearUpdateTransferConnection = function() { + delete $scope.updateZoneInfo.transferConnection; + $scope.updateZoneInfo.hiddenTransferKey = ''; + }; + + function handleError(error, type) { + var alert = utilityService.failure(error, type); + $scope.alerts.push(alert); + $scope.processing = false; + } + + function showSuccess(requestType, response) { + var msg = requestType + " " + response.statusText + " (HTTP "+response.status+"): "; + msg += $scope.zoneInfo.name + ' updated'; + $scope.alerts.push({type: "success", content: msg}); + $timeout($scope.refreshZone(), 2000); + } + + /** + * Global data-updating functions + */ + + $scope.refreshZone = function() { + function success(response) { + $log.log('recordsService::getZone-success'); + $scope.zoneInfo = response.data.zone; + $scope.updateZoneInfo = angular.copy($scope.zoneInfo); + $scope.updateZoneInfo.hiddenKey = ''; + $scope.updateZoneInfo.hiddenTransferKey = ''; + $scope.currentManageZoneState = $scope.manageZoneState.UPDATE; + $scope.refreshAclRuleDisplay(); + } + return recordsService + .getZone($scope.zoneId) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getZone-failure'); + }); + }; + + $scope.refreshAclRuleDisplay = function() { + $scope.aclRules = []; + angular.forEach($scope.zoneInfo.acl.rules, function (rule) { + $scope.aclRules.push(zonesService.toDisplayAclRule(rule)); + }); + }; + + /** + * Service interaction functions + */ + + $scope.updateZone = function(zone, message) { + return zonesService + .updateZone($scope.zoneId, zone) + .then(function(response){showSuccess(message, response)}) + .catch(function (error){ + $timeout($scope.refreshZone(), 1000); + handleError(error, 'zonesService::updateZone-failure'); + }); + }; + + $timeout($scope.refreshZone, 0); +}); diff --git a/modules/portal/public/lib/controllers/controller.manageZones.spec.js b/modules/portal/public/lib/controllers/controller.manageZones.spec.js new file mode 100644 index 0000000000..b7f8087b14 --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.manageZones.spec.js @@ -0,0 +1,513 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Controller: ManageZonesController', function () { + beforeEach(function () { + module('ngMock'), + module('service.groups'), + module('service.records'), + module('service.utility'), + module('service.zones'), + module('service.profile'), + module('controller.manageZones') + }); + beforeEach(inject(function ($rootScope, $controller, $q, groupsService, recordsService, zonesService, + profileService) { + this.rootScope = $rootScope; + this.scope = $rootScope.$new(); + this.groupsService = groupsService; + this.zonesService = zonesService; + this.recordsService = recordsService; + this.profileService = profileService; + this.q = $q; + this.groupsService.getMyGroups = function () { + return $q.when({ + data: { + groups: "all my groups" + } + }); + }; + this.scope.addAclRuleForm = { + $setPristine: function(){} + }; + this.controller = $controller('ManageZonesController', {'$scope': this.scope}); + })); + + it('updateZone changes currentManageZoneState to CONFIRM_UPDATE', function() { + this.scope.currentManageZoneState = this.scope.manageZoneState.UPDATE; + this.scope.clickUpdateZone(); + expect(this.scope.currentManageZoneState).toBe(this.scope.manageZoneState.CONFIRM_UPDATE); + }); + + it('cancelZoneUpdate changes currentManageZoneState to UPDATE', function() { + this.scope.currentManageZoneState = this.scope.manageZoneState.CONFIRM_UPDATE; + this.scope.cancelUpdateZone(); + expect(this.scope.currentManageZoneState).toBe(this.scope.manageZoneState.UPDATE); + }); + + it('submitUpdateZone calls updateZone', function() { + this.scope.currentManageZoneState = this.scope.manageZoneState.CONFIRM_UPDATE; + var normalizeZoneDates = spyOn(this.zonesService, 'normalizeZoneDates') + .and.stub(); + var setConnectionKeys = spyOn(this.zonesService, 'setConnectionKeys') + .and.stub(); + var updateZone = spyOn(this.scope, 'updateZone') + .and.stub(); + + this.scope.submitUpdateZone(); + + expect(normalizeZoneDates.calls.count()).toBe(1); + expect(setConnectionKeys.calls.count()).toBe(1); + expect(updateZone.calls.count()).toBe(1); + expect(this.scope.currentManageZoneState).toBe(this.scope.manageZoneState.UPDATE); + }); + + it('objectsDiffer passes if zone objects are same', function() { + mockZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234' + }; + mockUpdateZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234' + }; + var normalizeZone = spyOn(this.scope, 'normalizeZone'); + var objectsDiffer = this.scope.objectsDiffer(mockZone, mockUpdateZone); + expect(normalizeZone.calls.count()).toBe(2); + expect(objectsDiffer).toBeFalsy(); + }); + + it('objectsDiffer fails if zone objects are different', function() { + mockZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234' + }; + mockUpdateZone = { + name: 'vinyldns.', + email: 'update@example.com', + adminGroupId: '1234' + }; + var normalizeZone = spyOn(this.scope, 'normalizeZone') + .and.callThrough(); + var objectsDiffer = this.scope.objectsDiffer(mockZone, mockUpdateZone); + expect(normalizeZone.calls.count()).toBe(2); + expect(objectsDiffer).toBeTruthy(); + }); + + it('normalizeZone removes display attributes', function() { + mockZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + adminGroupName: 'name', + hiddenKey: 'key', + hiddenTransferKey: 'key' + }; + expectedZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234' + }; + var zone = this.scope.normalizeZone(mockZone); + expect(zone).toEqual(expectedZone); + }); + + it('clearUpdateConnection clears updateZoneInfo connection', function() { + mockUpdateZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + connection: { + name: "connection-name", + keyName: "connection-key-name", + key: "connection-key", + primaryServer: "connection-server" + }, + hiddenKey: 'new key', + hiddenTransferKey: 'new key' + }; + mockUpdateZoneCleared = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + hiddenKey: '', + hiddenTransferKey: 'new key' + }; + this.scope.updateZoneInfo = mockUpdateZone; + this.scope.clearUpdateConnection(); + expect(this.scope.updateZoneInfo).toEqual(mockUpdateZoneCleared); + }); + + it('clearUpdateTransferConnection clears updateZoneInfo transferConnection', function() { + mockUpdateZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + transferConnection: { + name: "connection-name", + keyName: "connection-key-name", + key: "connection-key", + primaryServer: "connection-server" + }, + hiddenKey: 'new key', + hiddenTransferKey: 'new key' + }; + mockUpdateZoneCleared = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + hiddenKey: 'new key', + hiddenTransferKey: '' + }; + this.scope.updateZoneInfo = mockUpdateZone; + this.scope.clearUpdateTransferConnection(); + expect(this.scope.updateZoneInfo).toEqual(mockUpdateZoneCleared); + }); + + it('refresh zone properly refreshes zone', function() { + this.scope.zoneInfo = { + 'adminGroupId': 'id101112' + }; + + mockResponse = { + data: { + zone: { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: 'id101112', + adminGroupName: 'name', + hiddenKey: 'key', + hiddenTransferKey: 'key' + } + } + }; + var getZone = spyOn(this.recordsService, 'getZone') + .and.stub() + .and.returnValue(this.q.when(mockResponse)); + var refreshAclRuleDisplay = spyOn(this.scope, 'refreshAclRuleDisplay') + .and.stub(); + this.scope.currentManageZoneState = this.scope.manageZoneState.CONFIRM_UPDATE; + this.scope.updateZoneInfo.hiddenKey = 'some key'; + this.scope.updateZoneInfo.hiddenTransferKey = 'some key'; + this.scope.refreshZone(); + this.scope.$digest(); + expect(getZone.calls.count()).toBe(1); + expect(refreshAclRuleDisplay.calls.count()).toBe(1); + expect(this.scope.zoneInfo).toEqual(mockResponse.data.zone); + expect(this.scope.updateZoneInfo. adminGroupId).toEqual('id101112'); + expect(this.scope.updateZoneInfo.hiddenKey).toEqual(''); + expect(this.scope.updateZoneInfo.hiddenTransferKey).toEqual(''); + expect(this.scope.currentManageZoneState).toBe(this.scope.manageZoneState.UPDATE); + }); + + it('refresh zone properly adds error to alerts when failing', function() { + mockError = { + status: '404', + statusText: 'Not Found', + data: 'Zone not found' + }; + var getZone = spyOn(this.recordsService, 'getZone') + .and.stub() + .and.returnValue(this.q.reject(mockError)); + + this.scope.myGroups = [{'id': 'id123'}, {'id': 'id456'}, {'id': 'id789'}]; + this.scope.zoneInfo = { + 'adminGroupId': 'id101112' + }; + + var getGroupResponse = { + 'data': { + 'name': 'groupName' + } + }; + + var getGroup = spyOn(this.groupsService, 'getGroup') + .and.stub() + .and.returnValue(getGroupResponse); + + this.scope.refreshZone(); + this.scope.$digest(); + expect(getZone.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([{ + type: 'danger', + content: "HTTP 404 (Not Found): Zone not found" + }]); + }); + + it('updateZone successfully calls updateZone', function() { + var updateZone = spyOn(this.zonesService, 'updateZone') + .and.stub() + .and.returnValue(this.q.when('response')); + this.scope.updateZone(); + expect(updateZone.calls.count()).toBe(1); + }); + + it('updateZone properly adds error to alerts when failing', function() { + mockError = { + status: '404', + statusText: 'Not Found', + data: 'Zone not found' + }; + var updateZone = spyOn(this.zonesService, 'updateZone') + .and.stub() + .and.returnValue(this.q.reject(mockError)); + var refreshZone = spyOn(this.scope, 'refreshZone') + .and.stub(); + this.scope.updateZone(); + this.scope.$digest(); + expect(updateZone.calls.count()).toBe(1); + expect(refreshZone.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([{ + type: 'danger', + content: "HTTP 404 (Not Found): Zone not found" + }]); + }); + + it('clickCreateAclRule properly sets up currentAclRule and aclModal', function() { + this.scope.currentAclRule = {}; + this.scope.aclModal = {}; + expectedCurrentAclRule = { + priority: 'User', + accessLevel: 'Read' + }; + expectedAclModal = { + action: this.scope.aclModalState.CREATE, + title: 'Create ACL Rule', + details: this.scope.aclModalParams.editable + }; + this.scope.clickCreateAclRule(); + expect(this.scope.currentAclRule).toEqual(expectedCurrentAclRule); + expect(this.scope.aclModal).toEqual(expectedAclModal); + }); + + it('clickDeleteAclRule properly sets up currentAclRule and aclModal', function() { + this.scope.currentAclRuleIndex = {}; + this.scope.aclModal = {}; + + var expectedCurrentAclRuleIndex = 0; + var expectedAclModal = { + action: this.scope.aclModalState.CONFIRM_DELETE, + title: 'Delete ACL Rule', + details: this.scope.aclModalParams.readOnly + }; + this.scope.clickDeleteAclRule(0); + expect(this.scope.currentAclRuleIndex).toEqual(expectedCurrentAclRuleIndex); + expect(this.scope.aclModal).toEqual(expectedAclModal); + }); + + it('clickUpdateAclRule properly sets up currentAclRule and aclModal', function() { + this.scope.currentAclRuleIndex = {}; + this.scope.currentAclRule = {}; + this.scope.aclModal = {}; + var mockRule = { + accessLevel: 'Read', + recordType: ['A','AAAA'] + }; + this.scope.aclRules = [mockRule]; + + var expectedCurrentAclRuleIndex = 0; + var expectedCurrentAclRule = mockRule; + var expectedAclModal = { + action: this.scope.aclModalState.UPDATE, + title: 'Update ACL Rule', + details: this.scope.aclModalParams.editable + }; + this.scope.clickUpdateAclRule(0); + expect(this.scope.currentAclRuleIndex).toEqual(expectedCurrentAclRuleIndex); + expect(this.scope.currentAclRule).toEqual(expectedCurrentAclRule); + expect(this.scope.aclModal).toEqual(expectedAclModal); + }); + + it('confirmUpdateAclRule changes aclModal action to correct state', function() { + this.scope.aclModal = {}; + this.scope.confirmUpdateAclRule(true); + expect(this.scope.aclModal.action).toBe(this.scope.aclModalState.CONFIRM_UPDATE); + + this.scope.aclModal = {}; + this.scope.confirmUpdateAclRule(false); + expect(this.scope.aclModal.action).toBe(this.scope.aclModalState.UPDATE); + }); + + it('clearForm correctly sets currentAclRule', function() { + this.scope.currentAclRule = {}; + var expectedCurrentAclRule = { + priority: 'User', + accessLevel: 'Read' + }; + this.scope.clearForm(); + expect(this.scope.currentAclRule).toEqual(expectedCurrentAclRule); + }); + + it('submitAclRule works as expected when priority is not user', function() { + this.scope.addAclRuleForm.$valid = true; + this.scope.currentAclRule = { + accessLevel: 'Read', + recordType: ['A','AAAA'] + }; + var postUserLookup = spyOn(this.scope, 'postUserLookup') + .and.stub(); + + this.scope.submitAclRule('Create'); + expect(postUserLookup.calls.count()).toBe(1); + }); + + it('submitAclRule works as expected when priority is user', function() { + this.scope.addAclRuleForm.$valid = true; + var mockRule = { + priority: 'User', + userName: 'ntid', + accessLevel: 'Read', + recordType: ['A','AAAA'] + }; + var mockZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + acl: { + rules: [] + } + }; + this.scope.currentAclRule = mockRule; + this.scope.zoneInfo = mockZone; + var getUserDataByUsername = spyOn(this.profileService, 'getUserDataByUsername') + .and.stub() + .and.returnValue(this.q.when({data: {id: 'found id'}})); + var postUserLookup = spyOn(this.scope, 'postUserLookup') + .and.stub(); + var expectedRule = { + priority: 'User', + userName: 'ntid', + accessLevel: 'Read', + recordType: ['A','AAAA'], + userId: 'found id' + }; + + this.scope.submitAclRule('Create'); + this.scope.$digest(); + expect(getUserDataByUsername.calls.count()).toBe(1); + expect(postUserLookup.calls.count()).toBe(1); + expect(this.scope.currentAclRule).toEqual(expectedRule); + }); + + it('postUserLookup works as expected when given Create', function() { + var mockRule = { + accessLevel: 'Read', + recordType: ['A','AAAA'], + priority: 'Group', + groupId: '1234' + }; + var mockZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + acl: { + rules: [] + } + }; + this.scope.currentAclRule = mockRule; + this.scope.zoneInfo = mockZone; + var toVinylAclRule = spyOn(this.zonesService, 'toVinylAclRule') + .and.stub() + .and.returnValue(mockRule); + var normalizeZoneDates = spyOn(this.zonesService, 'normalizeZoneDates') + .and.stub() + .and.returnValue(mockZone); + var updateZone = spyOn(this.scope, 'updateZone') + .and.stub(); + var expectedSentParamOne = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + acl: { + rules: [mockRule] + } + }; + var expectedSentParamTwo = 'ACL Rule Create'; + + this.scope.postUserLookup('Create'); + expect(toVinylAclRule.calls.count()).toBe(1); + expect(normalizeZoneDates.calls.count()).toBe(1); + expect(updateZone.calls.count()).toBe(1); + expect(updateZone.calls.mostRecent().args).toEqual([expectedSentParamOne, expectedSentParamTwo]); + }); + + it('postUserLookup works as expected when given Update', function() { + var oldRule = { + accessLevel: 'Read', + recordType: ['A','AAAA'], + priority: 'Group', + groupId: 'old id' + }; + var newRule = { + accessLevel: 'Read', + recordType: ['A','AAAA'], + priority: 'Group', + groupId: 'new id' + }; + var mockZone = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + acl: { + rules: [oldRule, oldRule] + } + }; + this.scope.currentAclRule = newRule; + this.scope.currentAclRuleIndex = 1; + this.scope.zoneInfo = mockZone; + var toVinylAclRule = spyOn(this.zonesService, 'toVinylAclRule') + .and.stub() + .and.returnValue(newRule); + var normalizeZoneDates = spyOn(this.zonesService, 'normalizeZoneDates') + .and.stub() + .and.returnValue(mockZone); + var updateZone = spyOn(this.scope, 'updateZone') + .and.stub(); + var expectedSentParamOne = { + name: 'vinyldns.', + email: 'test@example.com', + adminGroupId: '1234', + acl: { + rules: [oldRule, newRule] + } + }; + var expectedSentParamTwo = 'ACL Rule Update'; + + this.scope.postUserLookup('Update'); + expect(toVinylAclRule.calls.count()).toBe(1); + expect(normalizeZoneDates.calls.count()).toBe(1); + expect(updateZone.calls.count()).toBe(1); + expect(updateZone.calls.mostRecent().args).toEqual([expectedSentParamOne, expectedSentParamTwo]); + }); + + it('refreshAclRuleDisplay properly sets aclRules', function() { + this.scope.zoneInfo = { + acl: { + rules: ['rule', 'rule', 'rule'] + } + }; + var toDisplayAclRule = spyOn(this.zonesService, 'toDisplayAclRule') + .and.stub() + .and.returnValue('rule'); + + this.scope.refreshAclRuleDisplay(); + expect(toDisplayAclRule.calls.count()).toBe(3); + expect(this.scope.aclRules).toEqual(this.scope.zoneInfo.acl.rules); + }); +}); diff --git a/modules/portal/public/lib/controllers/controller.membership.js b/modules/portal/public/lib/controllers/controller.membership.js new file mode 100644 index 0000000000..e827a037eb --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.membership.js @@ -0,0 +1,214 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('controller.membership', []).controller('MembershipController', function ($scope, $log, $location, $timeout, + groupsService, profileService, utilityService) { + + $scope.membership = { members: [], group: {} }; + $scope.membershipLoaded = false; + $scope.alerts = []; + $scope.isGroupAdmin = false; + + function handleError(error, type) { + var alert = utilityService.failure(error, type); + $scope.alerts.push(alert); + } + + $scope.getGroupMemberList = function(groupId) { + function success(response) { + $log.log('groupsService::getGroupMemberList-success'); + return response.data; + } + return groupsService + .getGroupMemberList(groupId) + .then(success) + .catch(function (error) { + handleError(error, 'groupsService::getGroupMemberList-failure'); + }); + }; + + $scope.getGroup = function(groupId) { + function success(response) { + $log.log('groupsService::getGroup-success'); + return response.data; + } + return groupsService + .getGroup(groupId) + .then(success) + .catch(function (error) { + handleError(error, 'groupsService::getGroup-failure'); + }); + }; + + function determineAdmin() { + profileService.getAuthenticatedUserData().then( + function (results) { + var admins = $scope.membership.group.admins.map(function (id_json) { + return id_json['id']}); + var superUser = angular.isUndefined(results.data.isSuper) ? false : results.data.isSuper; + $scope.isGroupAdmin = admins.indexOf(results.data.id) > -1 || superUser; + }); + } + + $scope.resetNewMemberData = function() { + $scope.newMemberData = { + isAdmin : false, + login : "" + }; + }; + + $scope.addMember = function() { + $log.log('addGroupMember::newMemberData', $scope.newMemberData); + function lookupAccountSuccess(response) { + if (response.data) { + $scope.membership.group.members.push({ id: response.data.id }); + + if ($scope.newMemberData.isAdmin) { + $scope.membership.group.admins.push({ id: response.data.id }); + } + + function updateGroupSuccess(results) { + var alert = utilityService.success("Added " + $scope.newMemberData.login, response, + 'addMember::getUserDataByUsername successful'); + $scope.alerts.push(alert); + $scope.refresh(); + return results; + } + return groupsService + .updateGroup($scope.membership.group.id, $scope.membership.group) + .then(updateGroupSuccess) + .catch(function (error) { + $scope.refresh(); + handleError(error, 'groupsService::updateGroup-failure-catch'); + }); + } + } + + return profileService.getUserDataByUsername($scope.newMemberData.login) + .then(lookupAccountSuccess) + .catch(function (error) { + handleError(error, 'profileService::getUserDataByUsername-failure-catch'); + }); + }; + + $scope.removeMember = function(memberId) { + + var keepUser = function (user) { + return user.id != memberId; + }; + + $log.log('removing group member ' + memberId + ' from group ' + $scope.membership.group.id); + + $scope.membership.group.admins = $scope.membership.group.admins.filter(keepUser); + $scope.membership.group.members = $scope.membership.group.members.filter(keepUser); + + function success(results) { + var alert = utilityService.success("Successfully Removed Member", results, + "groupsService::updateGroup-success"); + $scope.alerts.push(alert); + $scope.refresh(); + return results.data; + } + //update the group + return groupsService + .updateGroup($scope.membership.group.id, $scope.membership.group) + .then(success) + .catch(function (error) { + $scope.refresh(); + handleError(error, 'groupsService::updateGroup-failure'); + }); + }; + + $scope.toggleAdmin = function(member) { + + var keepUser = function (user) { + return user.id != member.id; + }; + + $log.log('toggleAdmin::toggled for member', member); + + if(member.isAdmin) { + $log.log('toggleAdmin::toggled making an admin'); + $scope.membership.group.admins.push({ id: member.id }); + } else { + $log.log('toggleAdmin::toggled removing as admin'); + $scope.membership.group.admins = $scope.membership.group.admins.filter(keepUser); + } + + function success(results) { + var alert = utilityService.success("Toggled Status of " + member.userName, results, + "toggleAdmin::status toggled"); + $scope.alerts.push(alert); + $scope.refresh(); + return results.data; + } + + //update the group + return groupsService + .updateGroup($scope.membership.group.id, $scope.membership.group) + .then(success) + .catch(function (error) { + $scope.refresh(); + handleError(error, 'groupsService::updateGroup-failure'); + }); + }; + + + $scope.getGroupInfo = function (id) { + //store group membership + function getGroupSuccess(result) { + $log.log('refresh::getGroupSuccess-success', result); + //update groups + $scope.membership.group = result; + + determineAdmin(); + + function getGroupMemberListSuccess(result) { + $log.log('refresh::getGroupMemberList-success', result); + //update groups + $scope.membership.members = result.members; + $scope.membershipLoaded = true; + return result; + } + + return $scope.getGroupMemberList(id) + .then(getGroupMemberListSuccess) + .catch(function (error) { + handleError(error, 'refresh::getGroupMemberList-failure'); + }); + } + + return $scope.getGroup(id) + .then(getGroupSuccess) + .catch(function (error) { + handleError(error, 'refresh::getGroup-failure'); + }); + }; + + $scope.refresh = function () { + var id = $location.absUrl().toString(); + id = id.substring(id.lastIndexOf('/') + 1); + $log.log('loading group with id ', id); + + $scope.isGroupAdmin = false; + + $scope.resetNewMemberData(); + $scope.getGroupInfo(id); + }; + + + $timeout($scope.refresh, 0); +}); diff --git a/modules/portal/public/lib/controllers/controller.membership.spec.js b/modules/portal/public/lib/controllers/controller.membership.spec.js new file mode 100644 index 0000000000..685a620339 --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.membership.spec.js @@ -0,0 +1,395 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Controller: MembershipController', function () { + beforeEach(function () { + module('ngMock'), + module('service.groups'), + module('service.profile'), + module('service.utility'), + module('controller.membership') + }); + beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, utilityService) { + this.rootScope = $rootScope; + this.scope = $rootScope.$new(); + this.groupsService = groupsService; + this.profileService = profileService; + this.utilityService = utilityService; + this.q = $q; + var mockGroup = { + data: { + id: 'id', + admins: [{id: "adminId"}], + members: [{id: "adminId"}, {id: "nonAdmin"}] + } + }; + + var mockGroupList = { + data: { + maxItems: 100, + members: [ + { + id: "adminId", + userName: "user1", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser1" + }, + { + id: "nonAdmin", + userName: "user2", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser2" + }] + } + }; + this.groupsService.getGroup = function() { + return $q.when(mockGroup); + }; + this.groupsService.getGroupMemberList = function() { + return $q.when(mockGroupList); + }; + this.controller = $controller('MembershipController', {'$scope': this.scope}); + + this.mockSuccessAlert = "success"; + this.mockFailureAlert = "failure"; + this.utilitySuccess = spyOn(this.utilityService, 'success') + .and.stub() + .and.returnValue(this.mockSuccessAlert); + this.utilityFailure = spyOn(this.utilityService, 'failure') + .and.stub() + .and.returnValue(this.mockFailureAlert); + this.profileService.getAuthenticatedUserData = function() { + return $q.when({ + data: { + id: 'someId' + } + }) + }; + })); + + it('addMember correctly calls utilityService when passing getUserDataByUsername', function(){ + var mockUserAccount = { + data: { + id: 'id' + } + }; + this.scope.membership.group.members = []; + this.scope.newMemberData = { + login: 'login' + }; + var profileService = spyOn(this.profileService, 'getUserDataByUsername') + .and.stub() + .and.returnValue(this.q.when(mockUserAccount)); + var updateGroup = spyOn(this.groupsService, 'updateGroup') + .and.stub() + .and.returnValue(this.q.when("success")); + + this.scope.addMember(); + this.scope.$digest(); + + expect(profileService.calls.count()).toBe(1); + expect(updateGroup.calls.count()).toBe(1); + expect(this.utilitySuccess.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockSuccessAlert]); + }); + + it('addMember correctly calls utilityService when failing getUserDataByUsername', function(){ + var mockUserAccount = 'user'; + this.scope.newMemberData = { + login: 'login' + }; + var profileService = spyOn(this.profileService, 'getUserDataByUsername') + .and.stub() + .and.returnValue(this.q.reject(mockUserAccount)); + + this.scope.addMember(); + this.scope.$digest(); + + expect(profileService.calls.count()).toBe(1); + expect(this.utilityFailure.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockFailureAlert]); + }); + + it('addMember correctly calls utilityService when failing updateGroup', function(){ + this.scope.newMemberData = { + login: 'login' + }; + this.scope.membership.group.members = []; + var mockUserAccount = { + data: { + id: 'id' + } + }; + var profileService = spyOn(this.profileService, 'getUserDataByUsername') + .and.stub() + .and.returnValue(this.q.when(mockUserAccount)); + var updateGroup = spyOn(this.groupsService, 'updateGroup') + .and.stub() + .and.returnValue(this.q.reject(mockUserAccount)); + + this.scope.addMember(); + this.scope.$digest(); + + expect(profileService.calls.count()).toBe(1); + expect(this.utilityFailure.calls.count()).toBe(1); + expect(updateGroup.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockFailureAlert]); + }); + + it('removeMember correctly calls utilityService when passing updateGroup', function(){ + this.scope.membership.group = { + id: 'id', + admins: [], + members: [] + }; + this.scope.membership.group.members = []; + var mockUserAccount = { + data: { + id: 'id' + } + }; + var updateGroup = spyOn(this.groupsService, 'updateGroup') + .and.stub() + .and.returnValue(this.q.when(mockUserAccount)); + + this.scope.removeMember('memberId'); + this.scope.$digest(); + + expect(this.utilitySuccess.calls.count()).toBe(1); + expect(updateGroup.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockSuccessAlert]); + }); + + it('removeMember correctly calls utilityService when failing updateGroup', function(){ + this.scope.membership.group = { + id: 'id', + admins: [], + members: [] + }; + this.scope.membership.group.members = []; + var mockUserAccount = { + data: { + id: 'id' + } + }; + var updateGroup = spyOn(this.groupsService, 'updateGroup') + .and.stub() + .and.returnValue(this.q.reject(mockUserAccount)); + + this.scope.removeMember('memberId'); + this.scope.$digest(); + + expect(this.utilityFailure.calls.count()).toBe(1); + expect(updateGroup.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockFailureAlert]); + }); + + it('removeMember correctly calls utilityService when passing updateGroup', function(){ + var mockMember = { + id: 'id', + isAdmin: false, + userName: 'username' + }; + this.scope.membership.group = { + id: 'id', + admins: [], + members: [] + }; + var mockUserAccount = { + data: { + id: 'id' + } + }; + var updateGroup = spyOn(this.groupsService, 'updateGroup') + .and.stub() + .and.returnValue(this.q.when(mockUserAccount)); + + this.scope.toggleAdmin(mockMember); + this.scope.$digest(); + + expect(this.utilitySuccess.calls.count()).toBe(1); + expect(updateGroup.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockSuccessAlert]); + }); + + it('removeMember correctly calls utilityService when failing updateGroup', function(){ + var mockMember = { + id: 'id', + isAdmin: false, + userName: 'username' + }; + this.scope.membership.group = { + id: 'id', + admins: [], + members: [] + }; + var mockUserAccount = { + data: { + id: 'id' + } + }; + var updateGroup = spyOn(this.groupsService, 'updateGroup') + .and.stub() + .and.returnValue(this.q.reject(mockUserAccount)); + + this.scope.toggleAdmin(mockMember); + this.scope.$digest(); + + expect(this.utilityFailure.calls.count()).toBe(1); + expect(updateGroup.calls.count()).toBe(1); + expect(this.scope.alerts).toEqual([this.mockFailureAlert]); + }); + + it('refresh correctly handles error when failing getGroup', function() { + var getGroup = spyOn(this.scope, 'getGroup') + .and.stub() + .and.returnValue(this.q.reject('success')); + + this.scope.refresh(); + this.scope.$digest(); + + expect(getGroup.calls.count()).toBe(1); + expect(this.utilityFailure.calls.count()).toBe(1); + }); + + it('refresh correctly handles error when failing getGroupMemberList', function() { + var getGroupMemberList = spyOn(this.scope, 'getGroupMemberList') + .and.stub() + .and.returnValue(this.q.reject('failure')); + + this.scope.refresh(); + this.scope.$digest(); + + expect(getGroupMemberList.calls.count()).toBe(1); + expect(this.utilityFailure.calls.count()).toBe(1); + }); + + it('getGroupInfo sets the group info, admin status when the user is an admin', function() { + + spyOn(this.profileService, 'getAuthenticatedUserData') + .and.stub() + .and.returnValue(this.q.when({ + data: { + id: 'adminId' + } + })); + + this.scope.getGroupInfo("adminId"); + this.scope.$digest(); + + var expectedGroup = { id: 'id', + admins: [{id: "adminId"}], + members: [{id: "adminId"}, {id: "nonAdmin"}] }; + var expectedMembership = [ + { id: "adminId", + userName: "user1", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser1" + }, + { id: "nonAdmin", + userName: "user2", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser2" + }]; + + expect(this.scope.membership.group).toEqual(expectedGroup); + expect(this.scope.membership.members).toEqual(expectedMembership); + expect(this.scope.isGroupAdmin).toBe(true); + }); + + it('getGroupInfo sets the group info, admin status when the user is not an admin', function() { + + spyOn(this.profileService, 'getAuthenticatedUserData') + .and.stub() + .and.returnValue(this.q.when({ + data: { + id: 'nonAdmin' + } + })); + + this.scope.getGroupInfo("adminId"); + this.scope.$digest(); + + var expectedGroup = { id: 'id', + admins: [{id: "adminId"}], + members: [{id: "adminId"}, {id: "nonAdmin"}] }; + var expectedMembership = [ + { id: "adminId", + userName: "user1", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser1" + }, + { id: "nonAdmin", + userName: "user2", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser2" + }]; + + expect(this.scope.membership.group).toEqual(expectedGroup); + expect(this.scope.membership.members).toEqual(expectedMembership); + expect(this.scope.isGroupAdmin).toBe(false); + }); + + it('getGroupInfo sets the group info, admin status when the user is a super user', function() { + + spyOn(this.profileService, 'getAuthenticatedUserData') + .and.stub() + .and.returnValue(this.q.when({ + data: { + id: 'someOtherUser', + isSuper: true + } + })); + + this.scope.getGroupInfo("adminId"); + this.scope.$digest(); + + var expectedGroup = { id: 'id', + admins: [{id: "adminId"}], + members: [{id: "adminId"}, {id: "nonAdmin"}] }; + var expectedMembership = [ + { id: "adminId", + userName: "user1", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser1" + }, + { id: "nonAdmin", + userName: "user2", + firstName: "user", + isAdmin: true, + lastName: "name", + userName: "someUser2" + }]; + + expect(this.scope.membership.group).toEqual(expectedGroup); + expect(this.scope.membership.members).toEqual(expectedMembership); + expect(this.scope.isGroupAdmin).toBe(true); + }); +}); diff --git a/modules/portal/public/lib/controllers/controller.records.js b/modules/portal/public/lib/controllers/controller.records.js new file mode 100644 index 0000000000..74071e4f7e --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.records.js @@ -0,0 +1,500 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('controller.records', []) + .controller('RecordsController', function ($scope, $timeout, $log, recordsService, groupsService, pagingService, utilityService) { + + /** + * Scope data initial setup + */ + + $scope.query = ""; + $scope.alerts = []; + + $scope.recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SRV', 'PTR', 'SPF', 'SSHFP']; + $scope.sshfpAlgorithms = [{name: '(1) RSA', number: 1}, {name: '(2) DSA', number: 2}, {name: '(3) ECDSA', number: 3}, + {name: '(4) Ed25519', number: 4}]; + $scope.sshfpTypes = [{name: '(1) SHA-1', number: 1}, {name: '(2) SHA-256', number: 2}]; + + $scope.records = {}; + $scope.recordsetChangesPreview = {}; + $scope.recordsetChanges = {}; + $scope.currentRecord = {}; + $scope.zoneInfo = {}; + + $scope.recordModalState = { + CREATE: 0, + UPDATE: 1, + DELETE: 2, + CONFIRM_UPDATE: 3, + CONFIRM_DELETE: 4, + VIEW_DETAILS: 5 + }; + + $scope.disabledStates = [$scope.recordModalState.CONFIRM_UPDATE, $scope.recordModalState.CONFIRM_DELETE, $scope.recordModalState.VIEW_DETAILS]; + + // read-only data for setting various classes/attributes in record modal + $scope.recordModalParams = { + readOnly: { + class: "", + readOnly: true + }, + editable: { + class: "record-edit", + readOnly: false + } + }; + + $scope.isZoneAdmin = false; + + // paging status for recordsets + var recordsPaging = pagingService.getNewPagingParams(100); + + // paging status for record changes + var changePaging = pagingService.getNewPagingParams(100); + + /** + * Modal control functions + */ + + $scope.deleteRecord = function(record) { + $scope.currentRecord = angular.copy(record); + $scope.recordModal = { + action: $scope.recordModalState.CONFIRM_DELETE, + title: "Delete record", + basics: $scope.recordModalParams.readOnly, + details: $scope.recordModalParams.readOnly + }; + $("#record_modal").modal("show"); + }; + + $scope.createRecord = function() { + record = { + type: "A", + ttl: 300, + mxItems: [{preference:'', exchange:''}], + srvItems: [{priority:'', weight:'', port:'', target:''}], + sshfpItems: [{algorithm:'', type:'', fingerprint:''}] + }; + $scope.currentRecord = angular.copy(record); + $scope.recordModal = { + action: $scope.recordModalState.CREATE, + title: "Create record", + basics: $scope.recordModalParams.editable, + details: $scope.recordModalParams.editable + }; + $scope.addRecordForm.$setPristine(); + $("#record_modal").modal("show"); + }; + + $scope.editRecord = function(record) { + $scope.currentRecord = angular.copy(record); + $scope.recordModal = { + previous: angular.copy(record), + action: $scope.recordModalState.UPDATE, + title: "Update record", + basics: $scope.recordModalParams.readOnly, + details: $scope.recordModalParams.editable + }; + $scope.addRecordForm.$setPristine(); + $("#record_modal").modal("show"); + }; + + $scope.confirmUpdate = function() { + $scope.recordModal.action = $scope.recordModalState.CONFIRM_UPDATE; + $scope.recordModal.details = $scope.recordModalParams.readOnly; + }; + + $scope.closeRecordModal = function() { + $scope.addRecordForm.$setPristine(); + }; + + $scope.viewRecordInfo = function(record) { + $scope.currentRecord = recordsService.toDisplayRecord(record); + $scope.recordModal = { + action: $scope.recordModalState.VIEW_DETAILS, + title: "Record Info", + basics: $scope.recordModalParams.readOnly, + details: $scope.recordModalParams.readOnly + }; + $("#record_modal").modal("show"); + }; + + /** + * Form submission functions + */ + + $scope.submitDeleteRecord = function(record) { + deleteRecordSet(record); + $("#record_modal").modal("hide"); + }; + + $scope.submitCreateRecord = function() { + var record = angular.copy($scope.currentRecord); + record['onlyFour'] = true; + + if ($scope.addRecordForm.$valid) { + createRecordSet(record); + + $scope.addRecordForm.$setPristine(); + $("#record_modal").modal('hide'); + } + }; + + $scope.submitUpdateRecord = function () { + var record = angular.copy($scope.currentRecord); + record['onlyFour'] = true; + + if ($scope.addRecordForm.$valid) { + updateRecordSet(record); + + $scope.addRecordForm.$setPristine(); + $("#record_modal").modal('hide'); + } + }; + + /** + * Form helpers + */ + + $scope.recordsDiffer = function(left, right) { + return !angular.equals(left, right); + }; + + $scope.clearRecord = function(record) { + record.ttl = undefined; + record.data = undefined; + }; + + $scope.getZoneStatusLabel = function() { + switch($scope.zoneInfo["status"]) { + case 'Active': + return 'success'; + case 'Deleted': + return 'danger'; + default: + return 'info'; + } + }; + + $scope.getRecordChangeStatusLabel = function(status) { + switch(status) { + case 'Complete': + return 'success'; + case 'Failed': + return 'danger'; + default: + return 'info'; + } + }; + + $scope.addNewMx = function() { + var dataObj = {preference:'', exchange:''}; + $scope.currentRecord.mxItems.push(dataObj); + }; + + $scope.deleteMx = function(index) { + $scope.currentRecord.mxItems.splice(index, 1); + if($scope.currentRecord.mxItems.length == 0) { + $scope.addNewMx(); + } + }; + + $scope.addNewSrv = function() { + var dataObj = {priority:'', weight:'', port:'', target:''}; + $scope.currentRecord.srvItems.push(dataObj); + }; + + $scope.deleteSrv = function(index) { + $scope.currentRecord.srvItems.splice(index, 1); + if($scope.currentRecord.srvItems.length == 0) { + $scope.addNewSrv(); + } + }; + + $scope.addNewSshfp = function() { + var dataObj = {algorithm:'', type:'', fingerprint: ''}; + $scope.currentRecord.sshfpItems.push(dataObj); + }; + + $scope.deleteSshfp = function(index) { + $scope.currentRecord.sshfpItems.splice(index, 1); + if($scope.currentRecord.sshfpItems.length == 0) { + $scope.addNewSshfp(); + } + }; + + /** + * Service interaction functions + */ + + function deleteRecordSet(record) { + return recordsService + .delRecordSet($scope.zoneId, record.id) + .then(recordSetSuccess("Delete Record")) + .catch(function (error){ + handleError(error, 'recordsService::delRecordSet-failure'); + }); + } + + function createRecordSet(record) { + var payload = recordsService.toVinylRecord(record); + payload.zoneId = $scope.zoneId; + return recordsService + .createRecordSet($scope.zoneId, payload) + .then(recordSetSuccess("Create Record")) + .catch(function (error){ + handleError(error, 'recordsService::createRecordSet-failure'); + }); + } + + function updateRecordSet(record) { + var payload = recordsService.toVinylRecord(record); + payload.zoneId = $scope.zoneId; + return recordsService + .updateRecordSet($scope.zoneId, record.id, payload) + .then(recordSetSuccess("Update Record")) + .catch(function (error){ + handleError(error, 'recordsService::updateRecordSet-failure'); + }); + } + + function recordSetSuccess(action) { + return function(response) { + showSuccess(action, response); + $scope.refreshRecords(); + $scope.refreshRecordChangesPreview(); + $scope.refreshRecordChanges(); + } + } + + function handleError(error, type) { + var alert = utilityService.failure(error, type); + $scope.alerts.push(alert); + $scope.processing = false; + } + + function showSuccess(requestType, response) { + var msg = requestType + " " + response.statusText + " (HTTP "+response.status+"): "; + var recordSet = response.data.recordSet; + msg += recordSet.name+"/"+recordSet.type+" updated, status: '"+recordSet.status+"'"; + $scope.alerts.push({type: "success", content: msg}); + return response; + } + + function determineAdmin(){ + groupsService.getMyGroupsStored().then( + function (results) { + var groupIds = results['groups'].map(function(grp) {return grp['id']}); + $scope.isZoneAdmin = groupIds.indexOf($scope.zoneInfo.adminGroupId) > -1; + }) + } + + /** + * Global data-updating functions + */ + + $scope.refreshZone = function() { + function success(response) { + $log.log('recordsService::getZone-success'); + $scope.zoneInfo = response.data.zone; + // Determine if the current user is an admin of this zone + determineAdmin() + } + return recordsService + .getZone($scope.zoneId) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getZone-catch'); + }); + }; + + $scope.syncZone = function() { + function success(response) { + $log.log('recordsService::syncZone-success'); + location.reload(); + } + return recordsService + .syncZone($scope.zoneId) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::syncZone-failure'); + }); + }; + + $scope.refreshRecordChangesPreview = function() { + function success(response) { + $log.log('recordsService::getRecordSetChanges-success'); + var newChanges = []; + angular.forEach(response.data.recordSetChanges, function(change) { + newChanges.push(change); + }); + $scope.recordsetChangesPreview = newChanges; + } + return recordsService + .listRecordSetChanges($scope.zoneId, 5) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getRecordSetChanges-failure'); + }); + }; + + $scope.refreshRecordChanges = function() { + changePaging = pagingService.resetPaging(changePaging); + function success(response) { + $log.log('recordsService::getRecordSetChanges-success'); + changePaging.next = response.data.nextId; + updateChangeDisplay(response.data.recordSetChanges) + } + return recordsService + .listRecordSetChanges($scope.zoneId, changePaging.maxItems, undefined) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getRecordSetChanges-failure'); + }); + }; + + function updateChangeDisplay(changes) { + var newChanges = []; + angular.forEach(changes, function(change) { + newChanges.push(change); + }); + $scope.recordsetChanges = newChanges; + } + + $scope.refreshRecords = function() { + recordsPaging = pagingService.resetPaging(recordsPaging); + function success(response) { + $log.log('recordsService::getRecordSets-success ('+ response.data.recordSets.length +' records)'); + recordsPaging.next = response.data.nextId; + updateRecordDisplay(response.data.recordSets); + } + return recordsService + .getRecordSets($scope.zoneId, recordsPaging.maxItems, undefined, $scope.query) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getRecordSets-failure'); + }); + }; + + function updateRecordDisplay(records) { + var newRecords = []; + angular.forEach(records, function(record) { + newRecords.push(recordsService.toDisplayRecord(record, $scope.zoneInfo.name)); + }); + $scope.records = newRecords; + if($scope.records.length > 0) { + $("td.dataTables_empty").hide(); + } else { + $("td.dataTables_empty").show(); + } + } + + /** + * Recordset paging + */ + $scope.getRecordPageTitle = function() { + return pagingService.getPanelTitle(recordsPaging); + }; + + $scope.prevPageEnabled = function() { + return pagingService.prevPageEnabled(recordsPaging); + }; + + $scope.nextPageEnabled = function() { + return pagingService.nextPageEnabled(recordsPaging); + }; + + $scope.prevPage = function() { + var startFrom = pagingService.getPrevStartFrom(recordsPaging); + return recordsService + .getRecordSets($scope.zoneId, recordsPaging.maxItems, startFrom, $scope.query) + .then(function(response) { + recordsPaging = pagingService.prevPageUpdate(response.data.nextId, recordsPaging); + updateRecordDisplay(response.data.recordSets); + }) + .catch(function (error){ + handleError(error, 'recordsService::prevPage-failure'); + }); + }; + + $scope.nextPage = function() { + return recordsService + .getRecordSets($scope.zoneId, recordsPaging.maxItems, recordsPaging.next, $scope.query) + .then(function(response) { + var recordSets = response.data.recordSets; + recordsPaging = pagingService.nextPageUpdate(recordSets, response.data.nextId, recordsPaging); + + if(recordSets.length > 0 ){ + updateRecordDisplay(recordSets); + } + }) + .catch(function (error){ + handleError(error, 'recordsService::nextPage-failure'); + }); + }; + + + /** + * Record change paging + */ + $scope.getChangePageTitle = function() { + return pagingService.getPanelTitle(changePaging); + }; + + $scope.changePrevPageEnabled = function() { + return pagingService.prevPageEnabled(changePaging); + }; + + $scope.changeNextPageEnabled = function() { + return pagingService.nextPageEnabled(changePaging); + }; + + $scope.changePrevPage = function() { + var startFrom = pagingService.getPrevStartFrom(changePaging); + return recordsService + .listRecordSetChanges($scope.zoneId, changePaging.maxItems, startFrom) + .then(function(response) { + changePaging = pagingService.prevPageUpdate(response.data.nextId, changePaging); + updateChangeDisplay(response.data.recordSetChanges); + }) + .catch(function (error) { + handleError(error, 'recordsService::changePrevPage-failure'); + }); + }; + + $scope.changeNextPage = function() { + return recordsService + .listRecordSetChanges($scope.zoneId, changePaging.maxItems, changePaging.next) + .then(function(response) { + var changes = response.data.recordSetChanges; + changePaging = pagingService.nextPageUpdate(changes, response.data.nextId, changePaging); + + if(changes.length > 0 ){ + updateChangeDisplay(changes); + } + }) + .catch(function (error) { + handleError(error, 'recordsService::changeNextPage-failure'); + }); + }; + + $timeout($scope.refreshZone, 0); + $timeout($scope.refreshRecords, 0); + $timeout($scope.refreshRecordChangesPreview, 0); + $timeout($scope.refreshRecordChanges, 0); + +}); diff --git a/modules/portal/public/lib/controllers/controller.records.spec.js b/modules/portal/public/lib/controllers/controller.records.spec.js new file mode 100644 index 0000000000..d04b210886 --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.records.spec.js @@ -0,0 +1,252 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Controller: RecordsController', function () { + beforeEach(function() { + module('ngMock'), + module('service.groups'), + module('service.records'), + module('service.paging'), + module('service.utility'), + module('directives.modals.record.module'), + module('controller.records') + }); + beforeEach(inject(function ($rootScope, $controller, $httpBackend, $q, groupsService, recordsService, pagingService) { + this.rootScope = $rootScope; + this.scope = this.rootScope.$new(); + this.$httpBackend = $httpBackend; + this.controller = $controller('RecordsController',{'$scope':this.scope}); + this.groupsService = groupsService; + this.recordsService = recordsService; + this.pagingService = pagingService; + this.q = $q; + })); + + it('clearRecord should clear ttl and data', function () { + var record = {'ttl' : 300, 'data': 'some data'}; + this.scope.clearRecord(record); + expect(record.ttl).toBe(undefined); + expect(record.data).toBe(undefined); + }); + + it('getRecordChangeStatusLabel returns success if Completed', function () { + var goodStatus = "Complete"; + expect(this.scope.getRecordChangeStatusLabel(goodStatus)).toBe("success"); + }); + + it('getRecordChangeStatusLabel returns danger if Failed', function () { + var badStatus = "Failed"; + expect(this.scope.getRecordChangeStatusLabel(badStatus)).toBe("danger"); + }); + + it('getRecordChangeStatusLabel returns info if anything but Active or Deleted', function () { + var notActiveOrDelete = "notActiveOrDelete"; + expect(this.scope.getRecordChangeStatusLabel(notActiveOrDelete)).toBe("info"); + }); + + it('getZoneStatusLabel returns success if Active', function() { + this.scope.zoneInfo["status"] = "Active"; + expect(this.scope.getZoneStatusLabel()).toBe("success"); + }); + + it('getZoneStatusLabel returns danger if Deleted', function() { + this.scope.zoneInfo["status"] = "Deleted"; + expect(this.scope.getZoneStatusLabel()).toBe("danger"); + }); + + it('getZoneStatusLabel returns info if anything but Active or Deleted', function() { + this.scope.zoneInfo["status"] = "notActiveOrDelete"; + expect(this.scope.getZoneStatusLabel()).toBe("info"); + }); + + it('addNewSshfp should add an empty sshfp object to currentRecord.sshfpItems', function() { + this.scope.currentRecord.sshfpItems = []; + this.scope.addNewSshfp(); + expect(this.scope.currentRecord.sshfpItems).toEqual([{algorithm: '', type: '', fingerprint: ''}]); + }); + + it('deleteSshfp should delete the correct sshfp object from currentRecord.sshfpItems', function() { + this.scope.currentRecord.sshfpItems = [{algorithm: '1', type: '', fingerprint: ''}, + {algorithm: '2', type: '', fingerprint: ''}, + {algorithm: '3', type: '', fingerprint: ''}]; + this.scope.deleteSshfp(1); + expect(this.scope.currentRecord.sshfpItems).toEqual([{algorithm: '1', type: '', fingerprint: ''}, + {algorithm: '3', type: '', fingerprint: ''}]); + }); + + it('deleteSshfp should keep at least one sshfp object', function() { + this.scope.currentRecord.sshfpItems = [{algorithm: '1', type: '', fingerprint: ''}, + {algorithm: '2', type: '', fingerprint: ''}]; + this.scope.deleteSshfp(0); + this.scope.deleteSshfp(0); + expect(this.scope.currentRecord.sshfpItems).toEqual([{algorithm: '', type: '', fingerprint: ''}]); + }); + + it('refreshZone updates zoneInfo and isZoneAdmin when user is in admin group', function() { + mockZone = { + name: "dummy.", + email: "test@test.com", + status: "Active", + created: "2017-02-15T14:58:39Z", + account: "c8234503-bfda-4b80-897f-d74129051eaa", + acl: {rules: []}, + adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa", + id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666", + shared: false, + status: "Active" + }; + mockGroups = {data: { groups: [ + {id: "c8234503-bfda-4b80-897f-d74129051eaa", + name: "test", + email: "test@test.com", + admins: [{id: "7096b806-c12a-4171-ba13-7fabb523acee"}], + created: "2017-02-15T14:58:31Z", + members: [{id: "7096b806-c12a-4171-ba13-7fabb523acee"}], + status: "Active"} + ], + maxItems: 100}}; + + this.scope.zoneInfo = {}; + spyOn(this.recordsService, 'getZone') + .and.stub() + .and.returnValue(this.q.when({ data: {zone: mockZone}})); + spyOn(this.groupsService, 'getMyGroups') + .and.stub() + .and.returnValue(this.q.when(mockGroups)); + this.scope.refreshZone(); + this.scope.$digest(); + + expect(this.scope.zoneInfo).toEqual(mockZone); + expect(this.scope.isZoneAdmin).toBe(true); + }); + + it('refreshZone updates zoneInfo and isZoneAdmin when user is not in admin group', function() { + mockZone = { + name: "dummy.", + email: "test@test.com", + status: "Active", + created: "2017-02-15T14:58:39Z", + account: "c8234503-bfda-4b80-897f-d74129051eaa", + acl: {rules: []}, + adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa", + id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666", + shared: false, + status: "Active" + }; + mockGroups = {data: { groups: [ + {id: "some-other-id", + name: "test", + email: "test@test.com", + admins: [{id: "7096b806-c12a-4171-ba13-7fabb523acee"}], + created: "2017-02-15T14:58:31Z", + members: [{id: "7096b806-c12a-4171-ba13-7fabb523acee"}], + status: "Active"} + ], + maxItems: 100}}; + + this.scope.zoneInfo = {}; + spyOn(this.recordsService, 'getZone') + .and.stub() + .and.returnValue(this.q.when({ data: {zone: mockZone}})); + spyOn(this.groupsService, 'getMyGroups') + .and.stub() + .and.returnValue(this.q.when(mockGroups)); + this.scope.refreshZone(); + this.scope.$digest(); + + expect(this.scope.zoneInfo).toEqual(mockZone); + expect(this.scope.isZoneAdmin).toBe(false); + }); + + it('refresh should call getRecordSets with the correct parameters', function () { + var mockRecords = {data: { recordSets: [ + { name: "dummy", + records: [{address: "1.1.1.1"}], + status: "Active", + ttl: 38400, + type: "A"} + ], + maxItems: 100}}; + + var getRecordSets = spyOn(this.recordsService, 'getRecordSets') + .and.stub() + .and.returnValue(this.q.when(mockRecords)); + + var expectedZoneId = this.scope.zoneId; + var expectedMaxItems = 100; + var expectedStartFrom = undefined; + var expectedQuery = this.scope.query; + + this.scope.refreshRecords(); + + expect(getRecordSets.calls.count()).toBe(1); + expect(getRecordSets.calls.mostRecent().args).toEqual( + [expectedZoneId, expectedMaxItems, expectedStartFrom, expectedQuery]); + }); + + it('next page should call getRecordSets with the correct parameters', function () { + var mockRecords = {data: { recordSets: [ + { name: "dummy", + records: [{address: "1.1.1.1"}], + status: "Active", + ttl: 38400, + type: "A"} + ], + maxItems: 100}}; + + var getRecordSets = spyOn(this.recordsService, 'getRecordSets') + .and.stub() + .and.returnValue(this.q.when(mockRecords)); + + var expectedZoneId = this.scope.zoneId; + var expectedMaxItems = 100; + var expectedStartFrom = undefined; + var expectedQuery = this.scope.query; + + this.scope.nextPage(); + + expect(getRecordSets.calls.count()).toBe(1); + expect(getRecordSets.calls.mostRecent().args).toEqual( + [expectedZoneId, expectedMaxItems, expectedStartFrom, expectedQuery]); + }); + + it('prev page should call getRecordSets with the correct parameters', function () { + var mockRecords = {data: { recordSets: [ + { name: "dummy", + records: [{address: "1.1.1.1"}], + status: "Active", + ttl: 38400, + type: "A"} + ], + maxItems: 100}}; + + var getRecordSets = spyOn(this.recordsService, 'getRecordSets') + .and.stub() + .and.returnValue(this.q.when(mockRecords)); + + var expectedZoneId = this.scope.zoneId; + var expectedMaxItems = 100; + var expectedStartFrom = undefined; + var expectedQuery = this.scope.query; + + this.scope.prevPage(); + + expect(getRecordSets.calls.count()).toBe(1); + expect(getRecordSets.calls.mostRecent().args).toEqual( + [expectedZoneId, expectedMaxItems, expectedStartFrom, expectedQuery]); + }); + +}); diff --git a/modules/portal/public/lib/controllers/controller.zones.js b/modules/portal/public/lib/controllers/controller.zones.js new file mode 100644 index 0000000000..509939c44b --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.zones.js @@ -0,0 +1,193 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('controller.zones', []) + .controller('ZonesController', function ($scope, $http, $location, $log, recordsService, zonesService, profileService, + groupsService, utilityService, $timeout, pagingService) { + + $scope.alerts = []; + $scope.zonesLoaded = false; + $scope.hasZones = false; // Re-assigned each time zones are fetched without a query + + $scope.query = ""; + + // Paging status for zone sets + var zonesPaging = pagingService.getNewPagingParams(100); + + profileService.getAuthenticatedUserData().then(function (results) { + if (results.data) { + $scope.profile = results.data; + $scope.profile.active = 'zones'; + } + }, function () { + $scope.profile = $scope.profile || {}; + $scope.profile.active = 'zones'; + }); + + $scope.resetCurrentZone = function () { + $scope.currentZone = {}; + + if($scope.myGroups && $scope.myGroups.length) { + $scope.currentZone.adminGroupId = $scope.myGroups[0].id; + } + + $scope.currentZone.connection = {}; + $scope.currentZone.transferConnection = {}; + }; + + groupsService.getMyGroups().then(function (results) { + if (results.data) { + $scope.myGroups = results.data.groups; + } + $scope.resetCurrentZone(); + }); + + $scope.isGroupMember = function(groupId) { + var groupMember = $scope.myGroups.find(function(group) { + return groupId === group.id; + }); + return groupMember !== undefined + }; + + /* Refreshes zone data set and then re-displays */ + $scope.refreshZones = function () { + zonesPaging = pagingService.resetPaging(zonesPaging); + function success(response) { + $log.log('zonesService::getZones-success (' + response.data.zones.length + ' zones)'); + zonesPaging.next = response.data.nextId; + updateZoneDisplay(response.data.zones); + if (!$scope.query.length) { + $scope.hasZones = response.data.zones.length > 0; + } + } + + return zonesService + .getZones(zonesPaging.maxItems, undefined, $scope.query) + .then(success) + .catch(function (error) { + handleError(error, 'zonesService::getZones-failure'); + }); + }; + + function updateZoneDisplay (zones) { + $scope.zones = zones; + $scope.zonesLoaded = true; + $log.log("Displaying zones: ", $scope.zones); + if($scope.zones.length > 0) { + $("td.dataTables_empty").hide(); + } else { + $("td.dataTables_empty").show(); + } + } + + /* Set total number of zones */ + + $scope.addZoneConnection = function () { + zonesService.sendZone($scope.currentZone) + .then(function () { + $timeout($scope.refreshZones(), 1000); + $("#zone_connection_modal").modal("hide"); + }) + .catch(function (error){ + $("#zone_connection_modal").modal("hide"); + $scope.zoneError = true; + handleError(error, 'zonesService::sendZone-failure'); + }); + }; + + $scope.confirmDeleteZone = function (zoneInfo) { + $scope.currentZone = zoneInfo; + $("#delete_zone_connection_modal").modal("show"); + }; + + $scope.submitDeleteZone = function (id) { + zonesService.delZone(id) + .then(function () { + $("#delete_zone_connection_modal").modal("hide"); + $scope.refreshZones(); + }) + .catch(function (error) { + $("#delete_zone_connection_modal").modal("hide"); + $scope.zoneError = true; + handleError(error, 'zonesService::sendZone-failure'); + }); + }; + + $scope.cancel = function () { + $scope.resetCurrentZone(); + $("#modal_zone_connect").modal("hide"); + $("#delete_zone_connection_modal").modal("hide"); + }; + + function handleError(error, type) { + $scope.zoneError = true; + var alert = utilityService.failure(error, type); + $scope.alerts.push(alert); + + if(error.data !== undefined && error.data.errors !== undefined) { + var errors = error.data.errors; + for(i in errors) { + $scope.alerts.push({type: "danger", content:errors[i]}); + } + } + } + + /* + * Zone set paging + */ + $scope.prevPageEnabled = function () { + return pagingService.prevPageEnabled(zonesPaging); + }; + + $scope.nextPageEnabled = function () { + return pagingService.nextPageEnabled(zonesPaging); + }; + + $scope.getZonePageTitle = function () { + return pagingService.getPanelTitle(zonesPaging); + }; + + $scope.prevPage = function () { + var startFrom = pagingService.getPrevStartFrom(zonesPaging); + return zonesService + .getZones(zonesPaging.maxItems, startFrom, $scope.query) + .then(function(response) { + zonesPaging = pagingService.prevPageUpdate(response.data.nextId, zonesPaging); + updateZoneDisplay(response.data.zones); + }) + .catch(function (error) { + handleError(error,'zonesService::prevPage-failure'); + }); + }; + + $scope.nextPage = function () { + return zonesService + .getZones(zonesPaging.maxItems, zonesPaging.next, $scope.query) + .then(function(response) { + var zoneSets = response.data.zones; + zonesPaging = pagingService.nextPageUpdate(zoneSets, response.data.nextId, zonesPaging); + + if (zoneSets.length > 0) { + updateZoneDisplay(response.data.zones); + } + }) + .catch(function (error) { + handleError(error,'zonesService::nextPage-failure') + }); + }; + + $timeout($scope.refreshZones, 0); +}); diff --git a/modules/portal/public/lib/controllers/controller.zones.spec.js b/modules/portal/public/lib/controllers/controller.zones.spec.js new file mode 100644 index 0000000000..e8bea26199 --- /dev/null +++ b/modules/portal/public/lib/controllers/controller.zones.spec.js @@ -0,0 +1,102 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Controller: ZonesController', function () { + beforeEach(function () { + module('ngMock'), + module('service.groups'), + module('service.profile'), + module('service.records'), + module('service.zones'), + module('service.utility'), + module('service.paging'), + module('controller.zones') + }); + beforeEach(inject(function ($rootScope, $controller, $q, groupsService, profileService, recordsService, zonesService, utilityService, pagingService) { + this.scope = $rootScope.$new(); + this.groupsService = groupsService; + this.zonesService = zonesService; + this.zonesService.q = $q; + this.pagingService = pagingService; + + this.scope.myGroups = {}; + this.scope.zones = {}; + + profileService.getAuthenticatedUserData = function() { + return $q.when({data: {}}); + }; + groupsService.getMyGroups = function() { + return $q.when({ + data: { + groups: "all my groups" + } + }); + }; + zonesService.getZones = function() { + return $q.when({ + data: { + zones: ["all my zones"] + } + }); + }; + + this.controller = $controller('ZonesController', {'$scope': this.scope}); + })); + + it('test that we properly get users groups when loading ZonesController', function(){ + this.scope.$digest(); + expect(this.scope.myGroups).toBe("all my groups"); + }); + + it('nextPage should call getZones with the correct parameters', function () { + var getZoneSets = spyOn(this.zonesService, 'getZones') + .and.stub() + .and.returnValue(this.zonesService.q.when(mockZone)); + + var expectedMaxItems = 100; + var expectedStartFrom = undefined; + var expectedQuery = this.scope.query; + + this.scope.nextPage(); + + expect(getZoneSets.calls.count()).toBe(1); + expect(getZoneSets.calls.mostRecent().args).toEqual( + [expectedMaxItems, expectedStartFrom, expectedQuery]); + }); + + it('prevPage should call getZones with the correct parameters', function () { + var getZoneSets = spyOn(this.zonesService, 'getZones') + .and.stub() + .and.returnValue(this.zonesService.q.when(mockZone)); + + var expectedMaxItems = 100; + var expectedStartFrom = undefined; + var expectedQuery = this.scope.query; + + this.scope.prevPage(); + + expect(getZoneSets.calls.count()).toBe(1); + expect(getZoneSets.calls.mostRecent().args).toEqual( + [expectedMaxItems, expectedStartFrom, expectedQuery]); + + this.scope.nextPage(); + this.scope.prevPage(); + + expect(getZoneSets.calls.count()).toBe(3); + expect(getZoneSets.calls.mostRecent().args).toEqual( + [expectedMaxItems, expectedStartFrom, expectedQuery]); + }); +}); diff --git a/modules/portal/public/lib/controllers/controllers.js b/modules/portal/public/lib/controllers/controllers.js new file mode 100644 index 0000000000..7490cf2b0b --- /dev/null +++ b/modules/portal/public/lib/controllers/controllers.js @@ -0,0 +1,24 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('controllers.module', ['controller.records', 'controller.groups', 'controller.membership', + 'controller.zones', 'controller.manageZones']); + + +// Keeps buttons from staying depressed +$(".btn").mouseup(function(){ + $(this).blur(); +}); diff --git a/modules/portal/public/lib/directives/directives.js b/modules/portal/public/lib/directives/directives.js new file mode 100644 index 0000000000..c7f1ec50dd --- /dev/null +++ b/modules/portal/public/lib/directives/directives.js @@ -0,0 +1,17 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.module', ['directives.validations.module', 'directives.modals.module', 'directives.notifications.module']); diff --git a/modules/portal/public/lib/directives/directives.modals.body.js b/modules/portal/public/lib/directives/directives.modals.body.js new file mode 100644 index 0000000000..b8a66f6d87 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.body.js @@ -0,0 +1,25 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.body.module', []) + .directive('modalBody', function() { + return { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: "/public/templates/modal-body.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.modals.element.js b/modules/portal/public/lib/directives/directives.modals.element.js new file mode 100644 index 0000000000..d002cf7d71 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.element.js @@ -0,0 +1,29 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.element.module', []) + .directive('modalElement', function() { + return { + restrict: 'E', + transclude: true, + replace: true, + scope: { + 'label': '@', + 'invalidWhen': '=' + }, + templateUrl: "/public/templates/modal-element.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.modals.footer.js b/modules/portal/public/lib/directives/directives.modals.footer.js new file mode 100644 index 0000000000..5234c246aa --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.footer.js @@ -0,0 +1,25 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.footer.module', []) + .directive('modalFooter', function() { + return { + restrict: 'E', + replace: true, + transclude: true, + templateUrl: "/public/templates/modal-footer.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.modals.invalid.js b/modules/portal/public/lib/directives/directives.modals.invalid.js new file mode 100644 index 0000000000..fbddd0a3c1 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.invalid.js @@ -0,0 +1,26 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.invalid.module', []) + .directive('modalInvalid', function() { + return { + restrict: 'E', + transclude: true, + replace: true, + require: "^^modalElement", + templateUrl: "/public/templates/modal-invalid.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.modals.js b/modules/portal/public/lib/directives/directives.modals.js new file mode 100644 index 0000000000..84e9cf18c4 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.js @@ -0,0 +1,20 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.module', + ['directives.modals.element.module', 'directives.modals.invalid.module', 'directives.modals.modal.module', + 'directives.modals.record.module', 'directives.modals.body.module', 'directives.modals.footer.module', + 'directives.modals.zoneconnection.module']); diff --git a/modules/portal/public/lib/directives/directives.modals.modal.js b/modules/portal/public/lib/directives/directives.modals.modal.js new file mode 100644 index 0000000000..efacf74783 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.modal.js @@ -0,0 +1,29 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.modal.module', []) + .directive('modal', function() { + return { + restrict: 'E', + replace: true, + transclude: true, + scope: { + 'modalId': '@', + 'modalTitle': '@' + }, + templateUrl: "/public/templates/modal.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.modals.record.js b/modules/portal/public/lib/directives/directives.modals.record.js new file mode 100644 index 0000000000..a4a6a50bbc --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.record.js @@ -0,0 +1,24 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.record.module', []) + .directive('recordmodal', function() { + return { + restrict: 'E', + replace: true, + templateUrl: "/public/templates/record-modal.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.modals.zoneconnection.js b/modules/portal/public/lib/directives/directives.modals.zoneconnection.js new file mode 100644 index 0000000000..619a413927 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.modals.zoneconnection.js @@ -0,0 +1,24 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.modals.zoneconnection.module', []) + .directive('zoneconnection', function() { + return { + restrict: 'E', + replace: true, + templateUrl: "/public/templates/zoneconnection-modal.html" + } + }); diff --git a/modules/portal/public/lib/directives/directives.notifications.js b/modules/portal/public/lib/directives/directives.notifications.js new file mode 100644 index 0000000000..4d3b504a75 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.notifications.js @@ -0,0 +1,32 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.notifications.module', []) + .directive('notification', function($timeout) { + return { + restrict: 'E', + replace: true, + scope: { + ngModel: '=' + }, + templateUrl: "/public/templates/notification.html", + link: function(scope, element, attrs) { + $timeout(function() { + element.fadeOut(50); + }, 6000) + } + } + }); diff --git a/modules/portal/public/lib/directives/directives.validations.js b/modules/portal/public/lib/directives/directives.validations.js new file mode 100644 index 0000000000..8d24e5ab85 --- /dev/null +++ b/modules/portal/public/lib/directives/directives.validations.js @@ -0,0 +1,17 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.validations.module', ['directives.validations.zones.module']); diff --git a/modules/portal/public/lib/directives/directives.validations.zones.js b/modules/portal/public/lib/directives/directives.validations.zones.js new file mode 100644 index 0000000000..e80054d03a --- /dev/null +++ b/modules/portal/public/lib/directives/directives.validations.zones.js @@ -0,0 +1,43 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('directives.validations.zones.module', []) + .directive('validateTtl', function () { + //requires an isolated scope + var minTTL = 30; + return { + //restrict to an attribute type + restrict: 'A', + //element must have ng-model attribute + require: 'ngModel', + link: function (scope, el, attrs, ctrl) { + //add a parse that will process each time the value + //is parsed into the model when the user updates it. + + ctrl.$parsers.unshift(function (value) { + //if a number then it's valid + //test and set the validity after update. + var valid = !Number.isNaN(value) && Number(value) >= 30; + ctrl.$setValidity('invalidTTL', valid); + // if it's valid, return the value to the model, + // otherwise return undefined + + return valid ? value : undefined; + }); + } + } + }); + diff --git a/modules/portal/public/lib/services/groups/service.groups.js b/modules/portal/public/lib/services/groups/service.groups.js new file mode 100644 index 0000000000..97b46aacf3 --- /dev/null +++ b/modules/portal/public/lib/services/groups/service.groups.js @@ -0,0 +1,100 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('service.groups', []) + .service('groupsService', function ($http, $q, utilityService) { + + var _myGroupsPromise = undefined; + var _refreshMyGroups = true; + + this.urlBuilder = function (url, obj) { + var result = []; + for (var property in obj) { + if (obj[property] !== undefined && obj[property] !== null) { + result.push(encodeURIComponent(property) + '=' + encodeURIComponent(obj[property])); + } + } + var params = result.join('&'); + url = (params) ? url + '?' + params : url; + return url; + }; + + this.createGroup = function (data) { + var url = '/api/groups'; + return $http.post(url, data, {headers: utilityService.getCsrfHeader()}); + }; + + this.getGroup = function (id) { + var url = '/api/groups/' + id; + return $http.get(url); + }; + + this.deleteGroups = function (id) { + var url = '/api/groups/' + id; + return $http.delete(url, {headers: utilityService.getCsrfHeader()}); + }; + + this.updateGroup = function (id, data) { + var url = '/api/groups/' + id; + return $http.put(url, data, {headers: utilityService.getCsrfHeader()}); + }; + + this.getGroupMemberList = function (uuid, id, count) { + var url = '/api/groups/' + uuid + '/members'; + url = this.urlBuilder(url, { 'startFrom': id, 'maxItems': count }); + return $http.get(url); + }; + + this.addGroupMember = function (groupId, id, data) { + var url = '/api/groups/' + groupId + '/members/' + id; + return $http.put(url, data, {headers: utilityService.getCsrfHeader()}); + }; + + this.deleteGroupMember = function (groupId, id) { + var url = '/api/groups/' + groupId + '/members/' + id; + return $http.delete(url, {headers: utilityService.getCsrfHeader()}); + }; + + this.getMyGroups = function () { + var url = '/api/groups'; + url = this.urlBuilder(url, { maxItems: 1000}); + return $http.get(url); + }; + + this.getGroupListChanges = function (id, count, groupId) { + var url = '/api/groups/' + groupId + '/changes'; + url = this.urlBuilder(url, { 'startFrom': id, 'maxItems': count }); + return $http.get(url); + }; + + this.getMyGroupsStored = function () { + if (_refreshMyGroups || _myGroupsPromise == undefined) { + _myGroupsPromise = this.getMyGroups().then( + function(response) { + _refreshMyGroups = false; + return response.data; + }, + function(error) { + _refreshMyGroups = true; + return $q.reject(error); + } + ) + } + return _myGroupsPromise; + }; + }); diff --git a/modules/portal/public/lib/services/groups/service.groups.spec.js b/modules/portal/public/lib/services/groups/service.groups.spec.js new file mode 100644 index 0000000000..83dc2168da --- /dev/null +++ b/modules/portal/public/lib/services/groups/service.groups.spec.js @@ -0,0 +1,517 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Service: groupsService', function () { + beforeEach(module('ngMock')); + beforeEach(module('service.groups')); + beforeEach(module('service.utility')); + beforeEach(inject(function (groupsService, $httpBackend, utilityService) { + this.groupsService = groupsService; + this.$httpBackend = $httpBackend; + this.serializeParams = function (obj) { + var result = []; + for (var property in obj) + result.push(encodeURIComponent(property) + '=' + encodeURIComponent(obj[property])); + return result.join('&'); + }; + jasmine.getJSONFixtures().fixturesPath = 'base/mocks'; + })); + + it('should be defined', function () { + expect(this.groupsService).toBeDefined(); + }); + + it('should have getGroups method', function () { + expect(this.groupsService.getGroup).toBeDefined(); + }); + + it('should have createGroups method', function () { + expect(this.groupsService.createGroup).toBeDefined(); + }); + + it('should have deleteGroups method', function () { + expect(this.groupsService.deleteGroups).toBeDefined(); + }); + + it('createGroup method should return 202 with valid group', function (done) { + var url = '/api/groups'; + this.$httpBackend.when('POST', url).respond(202, getJSONFixture('mockGroupSubmit.json')); + this.groupsService.createGroup(getJSONFixture('mockGroupSubmit.json')) + .then(function (response) { + expect(response.status).toBe(202); + done(); + }, function (error) { + fail('createGroup expected 202, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('createGroup method should return 400 invalid group', function (done) { + var url = '/api/groups'; + this.$httpBackend.when('POST', url, getJSONFixture('mockGroupBadSubmit.json')) + .respond(function () { + return [400, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.createGroup(getJSONFixture('mockGroupBadSubmit.json')) + .then(function (response) { + fail('createGroup expected 400, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); + + it('createGroup method should return 409 existing group', function (done) { + var url = '/api/groups'; + this.$httpBackend.when('POST', url, getJSONFixture('mockGroupExistingSubmit.json')) + .respond(function () { + return [409, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.createGroup(getJSONFixture('mockGroupExistingSubmit.json')) + .then(function (response) { + fail('createGroup expected 409, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(409); + done(); + }); + this.$httpBackend.flush(); + }); + + it('deleteGroups method should return 202 with valid group', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('DELETE', url).respond(202, getJSONFixture('mockGroupSubmit.json')); + this.groupsService.deleteGroups('abc123') + .then(function (response) { + expect(response.status).toBe(202); + done(); + }, function (error) { + fail('deleteGroup expected 202, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('deleteGroups method should return 400 invalid group', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('DELETE', url) + .respond(function () { + return [400, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.deleteGroups('abc123') + .then(function (response) { + fail('deleteGroup expected 400, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); + + it('deleteGroups method should return 404 invalid group', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('DELETE', url) + .respond(function () { + return [404, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.deleteGroups('abc123') + .then(function (response) { + fail('deleteGroup expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(404); + done(); + }); + this.$httpBackend.flush(); + }); + + it('createGroups method should return 409 existing group', function (done) { + var url = '/api/groups'; + this.$httpBackend.when('POST', url, getJSONFixture('mockGroupExistingSubmit.json')) + .respond(function () { + return [409, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.createGroup(getJSONFixture('mockGroupExistingSubmit.json')) + .then(function (response) { + fail('createGroup expected 409, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(409); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroup method should return json', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('GET', url).respond(getJSONFixture('mockGroup.json')); + this.groupsService.getGroup('abc123').then(function (response) { + expect(response.data).toEqual(getJSONFixture('mockGroup.json')); + done(); + }, function (error) { + fail('getGroup expected 202 with json, but got' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroupMemberList method should return 200 with valid group', function (done) { + var uuid = 123; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/members'; + this.$httpBackend.whenRoute('GET', url).respond(200, getJSONFixture('mockGroupGetMemberList.json')); + this.groupsService.getGroupMemberList(uuid, id, count) + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('getGroupMemberList expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroupMemberList method should return 400 with invalid group', function (done) { + var uuid = 123; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/members'; + this.$httpBackend.whenRoute('GET', url).respond(400, getJSONFixture('mockGroupGetMemberList.json')); + this.groupsService.getGroupMemberList(uuid, id, count) + .then(function (response) { + fail('getGroupMemberList expected 400, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroupMemberList method should return 403 with invalid group', function (done) { + var uuid = 123; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/members'; + this.$httpBackend.whenRoute('GET', url).respond(403, getJSONFixture('mockGroupGetMemberList.json')); + this.groupsService.getGroupMemberList(uuid, id, count) + .then(function (response) { + fail('getGroupMemberList expected 403, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(403); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroupMemberList method should return 503 with invalid group', function (done) { + var uuid = 123; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/members'; + this.$httpBackend.whenRoute('GET', url).respond(503, getJSONFixture('mockGroupGetMemberList.json')); + this.groupsService.getGroupMemberList(uuid, id, count) + .then(function (response) { + fail('getGroupMemberList expected 503, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(503); + done(); + }); + this.$httpBackend.flush(); + }); + + it('addGroupMember method should return 404 user id does not exist', function (done) { + var groupId = 6; + var id = 12; + var url = '/api/groups/:groupId/members/:id'; + this.$httpBackend.whenRoute('PUT', url) + .respond(function () { + return [404, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.addGroupMember(groupId, id) + .then(function (response) { + fail('addGroupMember expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(404); + done(); + }); + this.$httpBackend.flush(); + }); + + it('addGroupMember method should return 404 group does not exist', function (done) { + var groupId = 6; + var id = 12; + var url = '/api/groups/:groupId/members/:id'; + this.$httpBackend.whenRoute('PUT', url) + .respond(function () { + return [404, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.addGroupMember(groupId, id) + .then(function (response) { + fail('addGroupMember expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(404); + done(); + }); + this.$httpBackend.flush(); + }); + + it('addGroupMember method should return 200 group does exist', function (done) { + var uuid = 123; + var groupId = 6; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/members/:uuid'; + this.$httpBackend.whenRoute('PUT', url).respond(200, getJSONFixture('mockGroupGetMemberList.json')); + this.groupsService.addGroupMember(groupId, uuid, id, count) + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('addGroupMember expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('deleteGroupMember method should return 404 group does not exist', function (done) { + var groupId = 6; + var id = 12; + var url = '/api/groups/:groupId/members/:id'; + this.$httpBackend.whenRoute('DELETE', url) + .respond(function () { + return [404, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.deleteGroupMember(groupId, id) + .then(function (response) { + fail('deleteGroupMember expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(404); + done(); + }); + this.$httpBackend.flush(); + }); + + it('deleteGroupMember method should return 400 bad request', function (done) { + var groupId = 6; + var id = 12; + var url = '/api/groups/:groupId/members/:id'; + this.$httpBackend.whenRoute('DELETE', url) + .respond(function () { + return [400, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.deleteGroupMember(groupId, id) + .then(function (response) { + fail('deleteGroupMember expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); + + it('deleteGroupMember method should return 200 when member is deleted from group', function (done) { + var groupId = 6; + var id = 12; + var url = '/api/groups/:groupId/members/:uuid'; + this.$httpBackend.whenRoute('DELETE', url).respond(200, getJSONFixture('mockGroupGetMemberList.json')); + this.groupsService.deleteGroupMember(groupId, id) + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('deleteGroupMember expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('updateGroup method should return 200 with valid group', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('PUT', url).respond(200, getJSONFixture('mockGroupSubmit.json')); + this.groupsService.updateGroup('abc123') + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('updateGroup expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('updateGroup method should return 400 invalid group', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('PUT', url) + .respond(function () { + return [400, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.updateGroup('abc123') + .then(function (response) { + fail('updateGroup expected 400, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); + + it('updateGroup method should return 404 not found', function (done) { + var url = '/api/groups/:id'; + this.$httpBackend.whenRoute('PUT', url) + .respond(function () { + return [404, 'response body', {}, 'TestPhrase']; + }); + this.groupsService.updateGroup('abc123') + .then(function (response) { + fail('updateGroup expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(404); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroupListChanges method should return 200 with valid response', function (done) { + var uuid = 123; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/changes'; + this.$httpBackend.whenRoute('GET', url).respond(200, getJSONFixture('mockGroupListChanges.json')); + this.groupsService.getGroupListChanges(uuid, id, count) + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('getGroupListChanges expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getGroupListChanges method should return 404 group does not exist', function (done) { + var uuid = 123; + var id = 12; + var count = 2; + var url = '/api/groups/:groupId/changes'; + this.$httpBackend.whenRoute('GET', url).respond(404); + this.groupsService.getGroupListChanges(uuid, id, count) + .then(function (response) { + fail('getGroupListChanges expected 404, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(404); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getMyGroups method should return 200 with valid groups', function (done) { + var url = '/api/groups'; + this.$httpBackend.whenRoute('GET', url).respond(200, getJSONFixture('mockGroupList.json')); + this.groupsService.getMyGroups() + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('getMyGroups expected 200, but got ' + response.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getMyGroups method should return 401 with invalid credentials', function (done) { + var url = '/api/groups'; + this.$httpBackend.whenRoute('GET', url).respond(401, "The resource requires authentication, which was not supplied with the request"); + this.groupsService.getMyGroups() + .then(function (response) { + fail("getMyGroups expected 401, but got " + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(401); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getMyGroupsStored should only call the api on its 1st call', function (done) { + var url = '/api/groups'; + var groupsResult = getJSONFixture('mockGroupList.json'); + this.$httpBackend.whenRoute('GET', url).respond(200, groupsResult); + var getMyGroupsCalls = spyOn(this.groupsService, 'getMyGroups').and.callThrough(); + var getMyGroupsStoredCalls = spyOn(this.groupsService, 'getMyGroupsStored').and.callThrough(); + + this.groupsService.getMyGroupsStored().then(function (response) { + expect(response).toEqual(groupsResult); + }, function (error) { + fail('getGroup expected 202 with json, but got' + error.status.toString()); + }); + + this.$httpBackend.flush(); + + this.groupsService.getMyGroupsStored().then(function (response) { + expect(response).toEqual(groupsResult); + }, function (error) { + fail('getGroup expected 202 with json, but got' + error.status.toString()); + }); + + expect(getMyGroupsStoredCalls.calls.count()).toBe(2); + expect(getMyGroupsCalls.calls.count()).toBe(1); + done(); + + }); + + it('getMyGroupsStored should call the api twice when there is an error', function (done) { + var url = '/api/groups'; + this.$httpBackend.whenRoute('GET', url).respond(401, "The resource requires authentication, which was not supplied with the request"); + var getMyGroupsCalls = spyOn(this.groupsService, 'getMyGroups').and.callThrough(); + var getMyGroupsStoredCalls = spyOn(this.groupsService, 'getMyGroupsStored').and.callThrough(); + + this.groupsService.getMyGroupsStored().then(function (response) { + fail("getMyGroups expected 401, but got " + response.status.toString()); + }, function (error) { + expect(error.status).toBe(401); + }); + + this.$httpBackend.flush(); + + this.groupsService.getMyGroupsStored().then(function (response) { + fail("getMyGroups expected 401, but got " + response.status.toString()); + }, function (error) { + expect(error.status).toBe(401); + }); + + this.$httpBackend.flush(); + + expect(getMyGroupsStoredCalls.calls.count()).toBe(2); + expect(getMyGroupsCalls.calls.count()).toBe(2); + done(); + + }); +}); diff --git a/modules/portal/public/lib/services/paging/service.paging.js b/modules/portal/public/lib/services/paging/service.paging.js new file mode 100644 index 0000000000..f9d8d2fc87 --- /dev/null +++ b/modules/portal/public/lib/services/paging/service.paging.js @@ -0,0 +1,95 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('service.paging', []) + .service('pagingService', function () { + this.nextPageUpdate = function(data, nextId, paging){ + var newPage = angular.copy(paging); + if (data.length == 0) { + /* + * Vinyl sometimes returns a startFrom when there's not a next page. We should fix that in Vinyl + * itself. Once that's done, this logic can go away. + * + * For now, if someone clicks on "Next Page" but there's not anything there, we should just stay + * where we are and take away the "Next Page" button. Don't think there's anything more we can do + * without preloading (which seems overly complicated for a problem we should fix at the source). + */ + newPage.next = undefined; + } else { + if (paging.next) { + newPage.startKeys.push(paging.next); + } + newPage.next = nextId; + newPage.pageNum++; + } + return newPage + }; + + this.prevPageUpdate = function(nextId, paging) { + var newPage = angular.copy(paging); + newPage.startKeys.pop(); + newPage.next = nextId; + newPage.pageNum--; + + return newPage; + }; + + this.getPrevStartFrom = function(paging) { + var startFrom = undefined; + // page 0: there is no previous + // page 1: previous start key is undefined + // page 2: startKeys holds the current start key above previous key, so -2 + if (paging.pageNum > 1) { + startFrom = paging.startKeys[paging.startKeys.length - 2]; + } + return startFrom; + }; + + this.nextPageEnabled = function(paging) { + return Boolean(paging.next); + }; + + this.prevPageEnabled = function(paging) { + return paging.pageNum >= 1 + }; + + this.getPanelTitle = function(paging) { + return paging.pageNum > 0 ? "[Page "+(paging.pageNum+1)+"]" : "" + }; + + this.resetPaging = function(paging) { + var newPage = angular.copy(paging); + newPage.pageNum = 0; + newPage.startKeys = []; + newPage.next = undefined; + return newPage; + }; + + var emptyPaging = { + maxItems: 10, + pageNum: 0, + startKeys: [], + next: undefined + }; + + this.getNewPagingParams = function(maxItems) { + var temp = angular.copy(emptyPaging); + temp.maxItems = maxItems; + return temp + } + }); diff --git a/modules/portal/public/lib/services/paging/service.paging.spec.js b/modules/portal/public/lib/services/paging/service.paging.spec.js new file mode 100644 index 0000000000..c5c54ab849 --- /dev/null +++ b/modules/portal/public/lib/services/paging/service.paging.spec.js @@ -0,0 +1,105 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Service: pagingService', function () { + beforeEach(module('ngMock')); + beforeEach(module('service.paging')); + beforeEach(inject(function (pagingService) { + this.pagingService = pagingService; + })); + + + it('should be defined', function () { + expect(this.pagingService).toBeDefined(); + }); + + it('should give a base paging item based on input maxItems', function () { + var paging1 = this.pagingService.getNewPagingParams(10); + var paging2 = this.pagingService.getNewPagingParams(20); + + expect(paging1.maxItems).toEqual(10); + expect(paging2.maxItems).toEqual(20); + + expect(paging1.pageNum).toEqual(0); + expect(paging1.startKeys).toEqual([]); + expect(paging1.next).toBeUndefined(); + }); + + it('should reset values on an existing paging item', function () { + var oldPaging = { + maxItems: 25, + pageNum: 2, + startKeys: ["page1", "page2"], + next: "page3" + }; + + var paging = this.pagingService.resetPaging(oldPaging); + + expect(paging.maxItems).toEqual(25); + expect(paging.pageNum).toEqual(0); + expect(paging.startKeys).toEqual([]); + expect(paging.next).toBeUndefined(); + }); + + it('should update paging values for nextPage with empty data', function () { + var paging = { + maxItems: 25, + pageNum: 2, + startKeys: ["page1", "page2"], + next: "page3" + }; + + paging = this.pagingService.nextPageUpdate([], undefined, paging); + + expect(paging.maxItems).toEqual(25); + expect(paging.pageNum).toEqual(2); + expect(paging.startKeys).toEqual(["page1", "page2"]); + expect(paging.next).toBeUndefined(); + }); + + it('should update paging values for nextPage with data', function () { + var paging = { + maxItems: 2, + pageNum: 2, + startKeys: ["page1", "page2"], + next: "page3" + }; + + paging = this.pagingService.nextPageUpdate(["somedata", "somedata"], "page4", paging); + + expect(paging.maxItems).toEqual(2); + expect(paging.pageNum).toEqual(3); + expect(paging.startKeys).toEqual(["page1", "page2", "page3"]); + expect(paging.next).toEqual("page4"); + }); + + it('should update paging values for previous page', function () { + var paging = { + maxItems: 2, + pageNum: 2, + startKeys: ["page1", "page2"], + next: "page3" + }; + + paging = this.pagingService.prevPageUpdate("page2newkey", paging); + + expect(paging.maxItems).toEqual(2); + expect(paging.pageNum).toEqual(1); + expect(paging.startKeys).toEqual(["page1"]); + expect(paging.next).toEqual("page2newkey"); + }); + +}); diff --git a/modules/portal/public/lib/services/profile/service.profile.js b/modules/portal/public/lib/services/profile/service.profile.js new file mode 100644 index 0000000000..67910fe7fd --- /dev/null +++ b/modules/portal/public/lib/services/profile/service.profile.js @@ -0,0 +1,28 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('service.profile', []) + .service('profileService', function ($http) { + this.getAuthenticatedUserData = function () { + return $http.get('/api/users/currentuser'); + }; + + this.getUserDataByUsername = function(username){ + return $http.get('/api/users/lookupuser/' + username); + } + }); diff --git a/modules/portal/public/lib/services/profile/service.profile.spec.js b/modules/portal/public/lib/services/profile/service.profile.spec.js new file mode 100644 index 0000000000..c03b194a9f --- /dev/null +++ b/modules/portal/public/lib/services/profile/service.profile.spec.js @@ -0,0 +1,98 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Service: profileService', function () { + beforeEach(module('ngMock')); + beforeEach(module('service.profile')); + beforeEach(inject(function ($rootScope, $httpBackend, profileService) { + this.$rootScope = $rootScope; + this.scope = this.$rootScope.$new(); + this.$httpBackend = $httpBackend; + this.profileService = profileService; + + jasmine.getJSONFixtures().fixturesPath='base/mocks'; + })); + + it('should be defined', function () { + expect(this.profileService).toBeDefined(); + }); + + it('should have getAuthenticatedUserData method', function () { + expect(this.profileService.getAuthenticatedUserData()).toBeDefined(); + }); + + it('should have getUserDataByUsername method', function () { + expect(this.profileService.getUserDataByUsername).toBeDefined(); + }); + + it('getUserData method should return 200 with valid user', function (done) { + var url = '/api/users/currentuser'; + this.$httpBackend.whenRoute('GET', url).respond(200, getJSONFixture('mockUserData.json')); + this.profileService.getAuthenticatedUserData() + .then(function (response) { + expect(response.status).toBe(200); + done(); + }, function (error) { + fail('getUserData expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + it('getUserData method should return 400 with invalid user', function (done) { + var url = '/api/users/currentuser'; + this.$httpBackend.whenRoute('GET', url).respond(400); + this.profileService.getAuthenticatedUserData() + .then(function (response) { + fail('getUserData expected 400, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getUserDataByUsername method should return 200 with valid user', function (done) { + this.$httpBackend.expectGET('/api/users/lookupuser/username').respond('success'); + this.profileService.getUserDataByUsername('username') + .then(function (response) { + expect(response.status).toBe(200); + expect(response.data).toBe('success'); + done(); + }, function (error) { + fail('lookupUserAccount expected 200, but got ' + error.status.toString()); + done(); + }); + this.$httpBackend.flush(); + }); + + it('getUserDataByUsername method should return 400 with invalid user', function (done) { + var url = '/api/users/lookupuser/:uname'; + this.$httpBackend.whenRoute('GET', url) + .respond(function () { + return [400, 'response body', {}, 'TestPhrase']; + }); + this.profileService.getUserDataByUsername('badUsername') + .then(function (response) { + fail('lookupUserAccount expected 400, but got ' + response.status.toString()); + done(); + }, function (error) { + expect(error.status).toBe(400); + done(); + }); + this.$httpBackend.flush(); + }); +}); diff --git a/modules/portal/public/lib/services/records/service.records.js b/modules/portal/public/lib/services/records/service.records.js new file mode 100644 index 0000000000..597fdf1d24 --- /dev/null +++ b/modules/portal/public/lib/services/records/service.records.js @@ -0,0 +1,239 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('service.records', []) + .service('recordsService', function ($http, utilityService) { + + this.getRecordSets = function (id, limit, startFrom, query) { + if (query == "") { + query = null; + } + var params = { + "maxItems": limit, + "startFrom": startFrom, + "recordNameFilter": query + }; + var url = utilityService.urlBuilder("/api/zones/"+id+"/recordsets", params); + return $http.get(url); + }; + + this.createRecordSet = function(id, payload) { + return $http.post("/api/zones/"+id+"/recordsets", payload, {headers: utilityService.getCsrfHeader()}); + }; + + this.updateRecordSet = function(id, recordId, payload) { + return $http.put("/api/zones/"+id+"/recordsets/"+recordId, payload, {headers: utilityService.getCsrfHeader()}); + }; + + this.delRecordSet = function (zid, rid) { + return $http.delete("/api/zones/"+zid+"/recordsets/"+rid, {headers: utilityService.getCsrfHeader()}); + }; + + this.getZone = function (zid) { + return $http.get("/api/zones/"+zid); + }; + + this.syncZone = function (zid) { + return $http.post("/api/zones/"+zid+"/sync", {}, {headers: utilityService.getCsrfHeader()}); + }; + + this.listRecordSetChanges = function (zid, maxItems, startFrom) { + var url = '/api/zones/' + zid + '/recordsetchanges'; + var params = { + "maxItems": maxItems, + "startFrom": startFrom + }; + url = utilityService.urlBuilder(url, params); + return $http.get(url); + }; + + /* remove . from the end of a given string (for zone name). returns empty string if no argument given */ + function removeTrailingDot(str) { + if(typeof str === "undefined") { + return ""; + } + + if (str.charAt(str.length - 1) == '.'){ + return str.slice(0, str.length - 1); + } + + return str; + } + + function isDotted(record, zoneName) { + var canHaveDots = ['PTR', 'NS', 'SOA', 'SRV']; + + return canHaveDots.indexOf(record.type) == -1 && + record.name.indexOf(".") != -1 && + removeTrailingDot(record.name) != removeTrailingDot(zoneName) + } + + function isApex(recordName, zoneName) { + return removeTrailingDot(recordName) == removeTrailingDot(zoneName) || recordName == "@"; + } + + this.toDisplayRecord = function (record, zoneName) { + var newRecord = angular.copy(record); + newRecord.records = undefined; + newRecord.isDotted = isDotted(record, zoneName); + newRecord.canBeEdited = true; + switch (record.type) { + case 'A': + newRecord.aRecordData = []; + angular.forEach(record.records, function(aRecord) { + newRecord.aRecordData.push(aRecord.address); + }); + newRecord.onlyFour = true; + break; + case 'CNAME': + newRecord.cnameRecordData = record.records[0].cname; + break; + case 'MX': + newRecord.mxItems = []; + angular.forEach(record.records, function(item) { + newRecord.mxItems.push(item); + }); + newRecord.onlyFour = true; + break; + case 'AAAA': + newRecord.aaaaRecordData = []; + angular.forEach(record.records, function(aaaaRecord) { + newRecord.aaaaRecordData.push(aaaaRecord.address); + }); + newRecord.onlyFour = true; + break; + case 'TXT': + newRecord.textRecordData = record.records[0].text; + break; + case 'NS': + newRecord.nsRecordData = []; + angular.forEach(record.records, function(nsRecord) { + newRecord.nsRecordData.push(nsRecord.nsdname); + }); + newRecord.onlyFour = true; + newRecord.canBeEdited = !isApex(record.name, zoneName) + break; + case 'PTR': + newRecord.ptrRecordData = record.records[0].ptrdname; + break; + case 'SOA': + newRecord.soaMName = record.records[0].mname; + newRecord.soaRName = record.records[0].rname; + newRecord.soaSerial = record.records[0].serial; + newRecord.soaRefresh = record.records[0].refresh; + newRecord.soaRetry = record.records[0].retry; + newRecord.soaExpire = record.records[0].expire; + newRecord.soaMinimum = record.records[0].minimum; + newRecord.canBeEdited = false; + break; + case 'SRV': + newRecord.srvItems = []; + angular.forEach(record.records, function(item) { + newRecord.srvItems.push(item); + }); + newRecord.onlyFour = true; + break; + case 'SPF': + newRecord.spfRecordData = []; + angular.forEach(record.records, function(spfRecord) { + newRecord.spfRecordData.push(spfRecord.text); + }); + newRecord.onlyFour = true; + break; + case 'SSHFP': + newRecord.sshfpItems = []; + angular.forEach(record.records, function(item) { + newRecord.sshfpItems.push(item); + }); + newRecord.onlyFour = true; + break; + default: + } + return newRecord; + }; + + this.toVinylRecord = function (record) { + var newRecord = { + "id": record.id, + "name": record.name, + "type": record.type, + "ttl": Number(record.ttl) + }; + switch (record.type) { + case 'A': + newRecord.records = []; + angular.forEach(record.aRecordData, function(address) { + newRecord.records.push({"address": address}); + }); + break; + case 'CNAME': + newRecord.records = [{"cname": record.cnameRecordData}]; + break; + case 'MX': + newRecord.records = []; + angular.forEach(record.mxItems, function(record) { + newRecord.records.push({"preference": Number(record.preference), "exchange": record.exchange}) + }); + break; + case 'AAAA': + newRecord.records = []; + angular.forEach(record.aaaaRecordData, function(address) { + newRecord.records.push({"address": address}); + }); + break; + case 'TXT': + newRecord.records = [{"text": record.textRecordData}]; + break; + case 'PTR': + newRecord.records = [{"ptrdname": record.ptrRecordData}]; + break; + case 'SRV': + newRecord.records = []; + angular.forEach(record.srvItems, function(record) { + newRecord.records.push({"priority": Number(record.priority), + "weight": Number(record.weight), + "port": Number(record.port), + "target": record.target}); + }); + break; + case 'SPF': + newRecord.records = []; + angular.forEach(record.spfRecordData, function(text) { + newRecord.records.push({"text": text}); + }); + break; + case 'SSHFP': + newRecord.records = []; + angular.forEach(record.sshfpItems, function(record) { + newRecord.records.push({"algorithm": Number(record.algorithm), "type": Number(record.type), + "fingerprint": record.fingerprint}) + }); + break; + case 'NS': + newRecord.records = []; + angular.forEach(record.nsRecordData, function(address) { + newRecord.records.push({"nsdname": address}); + }); + break; + default: + } + return newRecord; + }; + + + }); diff --git a/modules/portal/public/lib/services/records/service.records.spec.js b/modules/portal/public/lib/services/records/service.records.spec.js new file mode 100644 index 0000000000..828e85efff --- /dev/null +++ b/modules/portal/public/lib/services/records/service.records.spec.js @@ -0,0 +1,252 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Service: recordsService', function () { + beforeEach(module('ngMock')); + beforeEach(module('service.utility')); + beforeEach(module('service.records')); + beforeEach(inject(function ($httpBackend, recordsService, utilityService) { + this.recordsService = recordsService; + this.$httpBackend = $httpBackend; + })); + + it('http backend gets called properly when getting record sets', function () { + this.$httpBackend.expectGET('/api/zones/id/recordsets?maxItems=100&startFrom=start&recordNameFilter=someQuery').respond('success'); + this.recordsService.getRecordSets('id', '100', 'start', 'someQuery') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when creating record sets', function () { + this.$httpBackend.expectPOST('/api/zones/id/recordsets').respond('success'); + this.recordsService.createRecordSet('id', 'record') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when updating record sets', function () { + this.$httpBackend.expectPUT('/api/zones/id/recordsets/recordid').respond('success'); + this.recordsService.updateRecordSet('id', 'recordid', 'record') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when deleting record sets', function () { + this.$httpBackend.expectDELETE('/api/zones/zoneid/recordsets/recordid').respond('success'); + this.recordsService.delRecordSet('zoneid', 'recordid') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when deleting record sets', function () { + this.$httpBackend.expectDELETE('/api/zones/zoneid/recordsets/recordid').respond('success'); + this.recordsService.delRecordSet('zoneid', 'recordid') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when getting zone', function () { + this.$httpBackend.expectGET('/api/zones/zoneid').respond('success'); + this.recordsService.getZone('zoneid') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when syncing zone', function () { + this.$httpBackend.expectPOST('/api/zones/zoneid/sync').respond('success'); + this.recordsService.syncZone('zoneid') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when listing record set changes', function () { + this.$httpBackend.expectGET('/api/zones/zoneid/recordsetchanges?maxItems=100').respond('success'); + this.recordsService.listRecordSetChanges('zoneid', '100') + .then(function(response) { + expect(response.data).toBe('success'); + }); + this.$httpBackend.flush(); + }); + + it('have toVinylRecord return a valid sshfp record', function() { + sentRecord = { + "id": 'recordId', + "name": 'recordName', + "type": 'SSHFP', + "ttl": '300', + "sshfpItems": [{algorithm: '1', type: '1', fingerprint: 'foo'}, + {algorithm: '2', type: '1', fingerprint: 'bar'}] + }; + expectedRecord = { + "id": 'recordId', + "name": 'recordName', + "type": 'SSHFP', + "ttl": 300, + "records": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}] + }; + + var actualRecord = this.recordsService.toVinylRecord(sentRecord); + expect(actualRecord).toEqual(expectedRecord) + }); + + it('have toDisplayRecord return a valid display record', function() { + vinyldnsRecord = { + "id": 'recordId', + "name": 'recordName', + "type": 'SSHFP', + "ttl": 300, + "records": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}] + }; + + displayRecord = { + "id": 'recordId', + "name": 'recordName', + "type": 'SSHFP', + "ttl": 300, + "records": undefined, + "sshfpItems": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}], + "onlyFour": true, + "isDotted": false, + "canBeEdited": true + }; + + var actualRecord = this.recordsService.toDisplayRecord(vinyldnsRecord); + expect(actualRecord).toEqual(displayRecord) + }); + + it('have toDisplayRecord return a valid display record for dotted host', function() { + vinyldnsRecord = { + "id": 'recordId', + "name": 'recordName.with.dot', + "type": 'SSHFP', + "ttl": 300, + "records": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}] + }; + + displayRecord = { + "id": 'recordId', + "name": 'recordName.with.dot', + "type": 'SSHFP', + "ttl": 300, + "records": undefined, + "sshfpItems": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}], + "onlyFour": true, + "isDotted": true, + "canBeEdited": true + }; + + var actualRecord = this.recordsService.toDisplayRecord(vinyldnsRecord); + expect(actualRecord).toEqual(displayRecord) + }); + + it('have toDisplayRecord return a valid display record for apex', function() { + vinyldnsRecord = { + "id": 'recordId', + "name": 'apex.with.dot', + "type": 'SSHFP', + "ttl": 300, + "records": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}] + }; + + displayRecord = { + "id": 'recordId', + "name": 'apex.with.dot', + "type": 'SSHFP', + "ttl": 300, + "records": undefined, + "sshfpItems": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}], + "onlyFour": true, + "isDotted": false, + "canBeEdited": true + }; + + var actualRecord = this.recordsService.toDisplayRecord(vinyldnsRecord, "apex.with.dot."); + expect(actualRecord).toEqual(displayRecord) + }); + + it('have toDisplayRecord return a valid display record for apex NS (both with trailing dot)', function() { + vinyldnsRecord = { + "id": 'recordId', + "name": 'apex.with.dot.', + "type": 'NS', + "ttl": 300, + "records": [{nsdname: "ns1.com."}, {nsdname: "ns2.com."}] + }; + + displayRecord = { + "id": 'recordId', + "name": 'apex.with.dot.', + "type": 'NS', + "ttl": 300, + "records": undefined, + "nsRecordData": ["ns1.com.", "ns2.com."], + "onlyFour": true, + "isDotted": false, + "canBeEdited": false + }; + + var actualRecord = this.recordsService.toDisplayRecord(vinyldnsRecord, "apex.with.dot."); + expect(actualRecord).toEqual(displayRecord) + }); + + it('have toDisplayRecord return a valid display record for apex (neither with trailing dot)', function() { + vinyldnsRecord = { + "id": 'recordId', + "name": 'apex.with.dot', + "type": 'SSHFP', + "ttl": 300, + "records": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}] + }; + + displayRecord = { + "id": 'recordId', + "name": 'apex.with.dot', + "type": 'SSHFP', + "ttl": 300, + "records": undefined, + "sshfpItems": [{algorithm: 1, type: 1, fingerprint: 'foo'}, + {algorithm: 2, type: 1, fingerprint: 'bar'}], + "onlyFour": true, + "isDotted": false, + "canBeEdited": true + }; + + var actualRecord = this.recordsService.toDisplayRecord(vinyldnsRecord, "apex.with.dot"); + expect(actualRecord).toEqual(displayRecord) + }); +}); diff --git a/modules/portal/public/lib/services/services.js b/modules/portal/public/lib/services/services.js new file mode 100644 index 0000000000..f50c7db466 --- /dev/null +++ b/modules/portal/public/lib/services/services.js @@ -0,0 +1,17 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +angular.module('services.module', ['service.profile', 'service.groups', 'service.zones', 'service.records', 'service.utility', 'service.paging']); diff --git a/modules/portal/public/lib/services/utility/service.utility.js b/modules/portal/public/lib/services/utility/service.utility.js new file mode 100644 index 0000000000..ecb22e4e56 --- /dev/null +++ b/modules/portal/public/lib/services/utility/service.utility.js @@ -0,0 +1,70 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('service.utility', []) + .service('utilityService', function ($log) { + + function stripQuotes(str) { + if (str[0] == '"' && str[str.length - 1] == '"') { + return str.substring(1, str.length - 1); + } else { + return str; + } + } + + this.failure = function (error, type) { + var msg = "HTTP " + error.status + " (" + error.statusText + "): "; + if (typeof error.data == "object") { + msg += error.data.errors.join("\n"); + } else { + msg += stripQuotes(error.data); + } + + $log.log(type, error); + return { + type: "danger", content: msg + }; + }; + + this.success = function(message, response, type) { + var msg = "HTTP " + response.status + " (" + response.statusText + "): " + message; + $log.log(type, response); + return { + type: "success", content: msg + }; + }; + + this.urlBuilder = function (url, obj) { + var result = []; + for (var property in obj) { + if (obj[property] !== undefined && obj[property] !== null) { + result.push(encodeURIComponent(property) + '=' + encodeURIComponent(obj[property])); + } + } + var params = result.join('&'); + url = (params) ? url + '?' + params : url; + return url; + }; + + this.getCsrfHeader = function () { + var csrfElement = document.getElementById("csrf"); + if (csrfElement) { + return {"Csrf-Token": csrfElement.getAttribute("content")} || null; + } else return {}; + } +}); diff --git a/modules/portal/public/lib/services/utility/service.utility.spec.js b/modules/portal/public/lib/services/utility/service.utility.spec.js new file mode 100644 index 0000000000..b56353b169 --- /dev/null +++ b/modules/portal/public/lib/services/utility/service.utility.spec.js @@ -0,0 +1,95 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Service: utilityService', function () { + beforeEach(module('ngMock')); + beforeEach(module('service.utility')); + beforeEach(inject(function (utilityService) { + this.utilityService = utilityService; + })); + + it('return danger alert when given error', function() { + var error = { + status: 'status code', + statusText: 'status text', + data: "error message" + }; + + var alert = this.utilityService.failure(error, "error"); + var expectedAlert = { + type: 'danger', + content: 'HTTP status code (status text): error message' + }; + expect(alert).toEqual(expectedAlert); + }); + + it('return danger alert when given error with multi-error data', function() { + var error = { + status: 404, + statusText: 'fail', + data: { + errors: [ + 'error message one', + 'error message two' + ] + } + }; + + var alert = this.utilityService.failure(error, "error"); + var expectedAlert = { + type: 'danger', + content: 'HTTP 404 (fail): error message one\nerror message two' + }; + expect(alert).toEqual(expectedAlert); + }); + + it('returns alert object for a promise success', function() { + var success = { + status: 'status code', + statusText: 'status text' + }; + var successMessage = 'success message'; + var type = "mockCall::mock-call-success"; + var alert = this.utilityService.success(successMessage, success, type); + var expectedAlert = { + type: 'success', + content: 'HTTP status code (status text): success message' + }; + expect(alert).toEqual(expectedAlert); + }); + + it('returns csrf header if meta tag is present in html', function() { + var meta = document.createElement("meta"); + meta.id = "csrf"; + meta.content = "test-csrf"; + document.getElementsByTagName('head')[0].appendChild(meta); + + var header = this.utilityService.getCsrfHeader(); + var expectedHeader = {"Csrf-Token": "test-csrf"}; + expect(header).toEqual(expectedHeader); + + document.getElementById("csrf").remove(); + }); + + it('returns empty object if meta tag is not present in html', function() { + var meta = document.getElementById("csrf"); + expect(meta).toEqual(null); + + var header = this.utilityService.getCsrfHeader(); + var expectedHeader = {}; + expect(header).toEqual(expectedHeader); + }); +}); diff --git a/modules/portal/public/lib/services/zones/service.zones.js b/modules/portal/public/lib/services/zones/service.zones.js new file mode 100644 index 0000000000..e4c51627d9 --- /dev/null +++ b/modules/portal/public/lib/services/zones/service.zones.js @@ -0,0 +1,160 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('service.zones', []) + .service('zonesService', function ($http, groupsService, $log, utilityService) { + + this.getZones = function (limit, startFrom, query) { + if (query == "") { + query = null; + } + var params = { + "maxItems": limit, + "startFrom": startFrom, + "nameFilter": query + }; + var url = groupsService.urlBuilder("/api/zones", params); + return $http.get(url); + }; + + this.sendZone = function (payload) { + var sanitizedPayload = this.sanitizeConnections(payload); + $log.info("service.zones: sending zone", sanitizedPayload); + return $http.post("/api/zones", sanitizedPayload, {headers: utilityService.getCsrfHeader()}); + }; + + this.delZone = function (id) { + return $http.delete("/api/zones/"+id, {headers: utilityService.getCsrfHeader()}); + }; + + this.updateZone = function (id, payload) { + var sanitizedPayload = this.sanitizeConnections(payload); + $log.info("service.zones: updating zone", sanitizedPayload); + return $http.put("/api/zones/"+id, sanitizedPayload, {headers: utilityService.getCsrfHeader()}); + }; + + this.sanitizeConnections = function(payload) { + var sanitizedPayload = {}; + angular.forEach(payload, function(value, key){ + if(key == "connection" || key == "transferConnection") { + var sanitizedConnection = sanitize(payload[key]); + if(!angular.equals(sanitizedConnection, {}) && + !(Object.keys(sanitizedConnection).length == 1 && 'name' in sanitizedConnection)) { + sanitizedConnection.name = payload.name; + sanitizedPayload[key] = sanitizedConnection; + } + } else { + sanitizedPayload[key] = value + } + }); + return sanitizedPayload; + }; + + this.normalizeZoneDates = function(zone) { + if (zone.created != undefined) { + zone.created = this.toApiIso(zone.created); + } + if (zone.updated != undefined) { + zone.updated = this.toApiIso(zone.updated); + } + if (zone.latestSync != undefined) { + zone.latestSync = this.toApiIso(zone.latestSync); + } + return zone; + }; + + this.setConnectionKeys = function(zone) { + if (zone.connection != undefined) { + if (zone.hiddenKey.trim() != '') { + zone.connection.key = zone.hiddenKey; + } else if (zone.hiddenKey.trim() == '' && zone.connection.keyName == '' && zone.connection.primaryServer == '') { + zone.connection.key = ''; + } + } + if (zone.transferConnection != undefined) { + if (zone.hiddenKey.trim() != '') { + zone.transferConnection.key = zone.hiddenKey; + } else if (zone.hiddenTransferKey.trim() == '' && zone.transferConnection.keyName == '' && zone.transferConnection.primaryServer == '') { + zone.transferConnection.key = ''; + } + } + return zone; + }; + + this.toApiIso = function(date) { + /* when we parse the DateTimes from the api it gets turned into javascript readable format + * instead of just staying the way it is, so for now converting it back to ISO format on the fly, + * ISO 8601 standards are YYYY-MM-DDTHH:MM:SS:SSSZ, but the DateTime ISO the api uses is + * YYYY-MM-DDTHH:MM:SSZ, so the SSS has to be dropped */ + return (new Date(date)).toISOString().slice(0,19) + 'Z'; + }; + + this.toDisplayAclRule = function(rule) { + var newRule = angular.copy(rule); + if (newRule.groupId != undefined) { + newRule.priority = "Group"; + } else if (newRule.userId != undefined) { + newRule.priority = "User"; + newRule.userName = newRule.displayName; + } else { + newRule.priority = "All Users"; + } + if (newRule.accessLevel == 'NoAccess') { + newRule.accessLevel = 'No Access'; + } + return newRule; + }; + + this.toVinylAclRule = function(rule) { + var newRule = { + accessLevel: rule.accessLevel, + description: rule.description, + recordMask: rule.recordMask, + recordTypes: rule.recordTypes, + displayName: rule.displayName + }; + switch (rule.priority) { + case 'User': + newRule.userId = rule.userId; + break; + case 'Group': + newRule.groupId = rule.groupId; + break; + default: + break; + } + if (newRule.accessLevel == 'No Access') { + newRule.accessLevel = 'NoAccess'; + } + + return newRule; + }; + + function sanitize(connection) { + + var sanitizedConnection = {}; + angular.forEach(connection, function(value, key) { + + if(value.trim() != "") { + sanitizedConnection[key] = value; + } + }); + + return sanitizedConnection; + } + }); diff --git a/modules/portal/public/lib/services/zones/service.zones.spec.js b/modules/portal/public/lib/services/zones/service.zones.spec.js new file mode 100644 index 0000000000..bcc37e4c5f --- /dev/null +++ b/modules/portal/public/lib/services/zones/service.zones.spec.js @@ -0,0 +1,400 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Service: zoneService', function () { + beforeEach(module('ngMock')); + beforeEach(module('service.zones')); + beforeEach(module('service.groups')); + beforeEach(module('service.utility')); + beforeEach(inject(function ($httpBackend, $q, zonesService, groupsService, utilityService) { + this.zonesService = zonesService; + this.groupsService = groupsService; + this.q = $q; + this.$httpBackend = $httpBackend; + })); + + it('http backend gets called properly when getting zones', function () { + this.$httpBackend.expectGET('/api/zones?maxItems=100&startFrom=start&nameFilter=someQuery').respond('zone returned'); + this.zonesService.getZones('100', 'start', 'someQuery') + .then(function(response) { + expect(response.data).toBe('zone returned'); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when sending zone', function (done) { + this.$httpBackend.expectPOST('/api/zones').respond('zone sent'); + this.zonesService.sendZone('zone payload') + .then(function(response) { + expect(response.data).toBe('zone sent'); + done(); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when deleting zone', function (done) { + this.$httpBackend.expectDELETE('/api/zones/id').respond('zone deleted'); + this.zonesService.delZone('id') + .then(function(response) { + expect(response.data).toBe('zone deleted'); + done(); + }); + this.$httpBackend.flush(); + }); + + it('sendZone should completely remove connections if they have empty objects', function(done) { + + var zonePayload = { + connection: {}, + transferConnection: {} + }; + + this.$httpBackend.expectPOST('/api/zones', {}).respond('zone sent'); + + this.zonesService.sendZone(zonePayload) + .then(function(response) { + expect(response.data).toBe('zone sent'); + done(); + }); + this.$httpBackend.flush(); + }); + + it('sendZone should completely remove connections if they have empty data', function(done) { + + var zonePayload = { + connection: { name: " "}, + transferConnection: { name: " " } + }; + + this.$httpBackend.expectPOST('/api/zones', {}).respond('zone sent'); + + this.zonesService.sendZone(zonePayload) + .then(function(response) { + expect(response.data).toBe('zone sent'); + done(); + }); + this.$httpBackend.flush(); + }); + + it('sendZone should add the zone name to the connection name', function(done) { + + var zonePayload = { + name: "frodo", + connection: { server: "middleEarth"}, + transferConnection: { server: "narnia" } + }; + + var sanitizedPayload = { + name: "frodo", + connection: { + name: "frodo", + server: "middleEarth" + }, + transferConnection: { + name: "frodo", + server: "narnia" + } + }; + + this.$httpBackend.expectPOST('/api/zones', sanitizedPayload).respond('zone sent'); + + this.zonesService.sendZone(zonePayload) + .then(function(response) { + expect(response.data).toBe('zone sent'); + done(); + }); + this.$httpBackend.flush(); + }); + + it('http backend gets called properly when updating a zone', function (done) { + this.$httpBackend.expectPUT('/api/zones/id').respond('update sent'); + var sanitizeConnections = spyOn(this.zonesService, 'sanitizeConnections'); + this.zonesService.updateZone('id', 'zone') + .then(function(response) { + expect(response.data).toBe('update sent'); + done(); + }); + expect(sanitizeConnections.calls.count()).toBe(1); + this.$httpBackend.flush(); + }); + + it('sanitizeConnections does not clear connection attribute when not empty', function() { + var payload = { + name: 'mockZone.', + connection: { + name: 'mockZone.', + keyName: 'key-name', + key: 'key-value', + server: 'server' + } + }; + var sanitizedPayload = this.zonesService.sanitizeConnections(payload); + expect(sanitizedPayload).toEqual(payload) + }); + + it('sanitizeConnections does adds name when not there', function() { + var payload = { + name: 'mockZone.', + connection: { + name: '', + keyName: 'key-name', + key: 'key-value', + server: 'server' + } + }; + var expectedPayload = { + name: 'mockZone.', + connection: { + name: 'mockZone.', + keyName: 'key-name', + key: 'key-value', + server: 'server' + } + }; + var sanitizedPayload = this.zonesService.sanitizeConnections(payload); + expect(sanitizedPayload).toEqual(expectedPayload) + }); + + it('sanitizeConnections does clear connection attribute when empty', function() { + var payload = { + name: 'mockZone.', + connection: { + name: '', + keyName: '', + key: '', + server: '' + } + }; + var expectedPayload = { + name: 'mockZone.' + }; + var sanitizedPayload = this.zonesService.sanitizeConnections(payload); + expect(sanitizedPayload).toEqual(expectedPayload) + }); + + it('sanitizeConnections does clear connection attribute when only name is left', function() { + var payload = { + name: 'mockZone.', + connection: { + name: 'mockZone.', + keyName: '', + key: '', + server: '' + } + }; + var expectedPayload = { + name: 'mockZone.' + }; + var sanitizedPayload = this.zonesService.sanitizeConnections(payload); + expect(sanitizedPayload).toEqual(expectedPayload) + }); + + it('toApiIso converts to correct format', function() { + var dateString = new Date().toDateString(); + var isoDate = this.zonesService.toApiIso(dateString); + + /* when we parse the DateTimes from the api it gets turned into javascript readable format + * instead of just staying the way it is, so for now converting it back to ISO format on the fly, + * ISO 8601 standards are YYYY-MM-DDTHH:MM:SS:SSSZ, but the DateTime ISO the api uses is + * YYYY-MM-DDTHH:MM:SSZ, so the SSS has to be dropped */ + expect(isoDate).toMatch("\\d{4}-\\d{2}-\\d{2}\\D\\d{2}:\\d{2}:\\d{2}Z"); + }); + + it('normalizeZoneDate changes DateTimes to api ISO format', function() { + var mockZone = { + created: new Date().toDateString() + }; + expect(mockZone.created).not.toMatch("\\d{4}-\\d{2}-\\d{2}\\D\\d{2}:\\d{2}:\\d{2}Z"); + var normalizedMockZone = this.zonesService.normalizeZoneDates(mockZone); + expect(normalizedMockZone.created).toMatch("\\d{4}-\\d{2}-\\d{2}\\D\\d{2}:\\d{2}:\\d{2}Z"); + }); + + it('setConnectionKeys properly sets connection key to be hidden Key', function() { + var mockZone = { + connection: { + name: 'vinyldns', + keyName: 'name', + key: 'key', + primaryServer: 'server' + }, + hiddenKey: 'new key' + }; + var expectedZone = { + connection: { + name: 'vinyldns', + keyName: 'name', + key: 'new key', + primaryServer: 'server' + }, + hiddenKey: 'new key' + }; + var newZone = this.zonesService.setConnectionKeys(mockZone); + expect(newZone).toEqual(expectedZone); + }); + + it('setConnectionKeys properly clears connection', function() { + var mockZone = { + connection: { + name: 'vinyldns', + keyName: '', + key: 'key', + primaryServer: '' + }, + hiddenKey: '' + }; + var expectedZone = { + connection: { + name: 'vinyldns', + keyName: '', + key: '', + primaryServer: '' + }, + hiddenKey: '' + }; + var newZone = this.zonesService.setConnectionKeys(mockZone); + expect(newZone).toEqual(expectedZone); + }); + + it('toVinylAclRule returns rule in correct format with accessLevel No Access', function() { + var mockAclRule = { + accessLevel: 'No Access', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'All Users' + }; + var expectedAclRule = { + accessLevel: 'NoAccess', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'All Users' + }; + var rule = this.zonesService.toVinylAclRule(mockAclRule); + expect(rule).toEqual(expectedAclRule); + }); + + it('toVinylAclRule returns rule in correct format with priority User', function() { + var mockAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'ntid', + priority: 'User', + userName: 'ntid', + userId: 'user id' + }; + var expectedAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'ntid', + userId: 'user id' + }; + var rule = this.zonesService.toVinylAclRule(mockAclRule); + expect(rule).toEqual(expectedAclRule); + }); + + it('toVinylAclRule returns rule in correct format with priority Group', function() { + var mockAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'group name', + priority: 'Group', + groupName: 'group name', + groupId: 'group id' + }; + var expectedAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'group name', + groupId: 'group id' + }; + var rule = this.zonesService.toVinylAclRule(mockAclRule); + expect(rule).toEqual(expectedAclRule); + }); + + it('toDisplayAclRule return rule in correct format when it has groupId', function() { + var mockAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'group name', + groupId: 'group id' + }; + var expectedAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'group name', + priority: 'Group', + groupId: 'group id' + }; + var rule = this.zonesService.toDisplayAclRule(mockAclRule); + expect(rule).toEqual(expectedAclRule); + }); + + it('toDisplayAclRule return rule in correct format when it has userId', function() { + var mockAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'ntid', + userId: 'user id' + }; + var expectedAclRule = { + accessLevel: 'Read', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'ntid', + priority: 'User', + userId: 'user id', + userName: 'ntid' + }; + var rule = this.zonesService.toDisplayAclRule(mockAclRule); + expect(rule).toEqual(expectedAclRule); + }); + + it('toDisplayAclRule return rule in correct format when it has NoAccess', function() { + var mockAclRule = { + accessLevel: 'NoAccess', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'All Users' + }; + var expectedAclRule = { + accessLevel: 'No Access', + description: 'description', + recordMask: 'mask', + recordTypes: ['A', 'AAAA'], + displayName: 'All Users', + priority: 'All Users' + }; + var rule = this.zonesService.toDisplayAclRule(mockAclRule); + expect(rule).toEqual(expectedAclRule); + }); +}); diff --git a/modules/portal/public/mocks/mockARecord.json b/modules/portal/public/mocks/mockARecord.json new file mode 100644 index 0000000000..8ce4096edb --- /dev/null +++ b/modules/portal/public/mocks/mockARecord.json @@ -0,0 +1,20 @@ +{ + "recordSets":[ + { + "type":"A", + "zoneId":"bf744806-fa76-4b5d-ac7d-bf5d974112c5", + "name":"arecord", + "ttl":300, + "status":"Active", + "created":"2016-05-24T18:51:03Z", + "updated":"2016-05-24T18:51:03Z", + "records":[ + { + "address":"10.2.2.2" + } + ], + "id":"f4b8fff2-0c84-4d8b-8f52-08f2791b0446", + "account":"testacct" + } + ] +} diff --git a/modules/portal/public/mocks/mockARecordSubmit.json b/modules/portal/public/mocks/mockARecordSubmit.json new file mode 100644 index 0000000000..10a8315ba3 --- /dev/null +++ b/modules/portal/public/mocks/mockARecordSubmit.json @@ -0,0 +1,11 @@ +{ + "zoneId":"abc123", + "name":"arecord.", + "type":"A", + "ttl":300, + "records":[ + { + "address":"10.2.2.2" + } + ] +} diff --git a/modules/portal/public/mocks/mockBadARecordSubmit.json b/modules/portal/public/mocks/mockBadARecordSubmit.json new file mode 100644 index 0000000000..5ca4a3eb5c --- /dev/null +++ b/modules/portal/public/mocks/mockBadARecordSubmit.json @@ -0,0 +1,11 @@ +{ + "zoneId":"abc123", + "name":"arecord", + "type":"A", + "ttl":300, + "records":[ + { + "address":"foo.2,bar" + } + ] +} diff --git a/modules/portal/public/mocks/mockBadGroupGetMemberList.json b/modules/portal/public/mocks/mockBadGroupGetMemberList.json new file mode 100644 index 0000000000..f3255624a9 --- /dev/null +++ b/modules/portal/public/mocks/mockBadGroupGetMemberList.json @@ -0,0 +1,12 @@ +{ + "members": [ + { + "userName": "foo123", + "firstName": "foo", + "lastName": "bar", + "email": "test@test.com", + "created": "2016-05-24T18:51:03Z", + "id": "abc123" + } + ] +} \ No newline at end of file diff --git a/modules/portal/public/mocks/mockBadZoneSubmit.json b/modules/portal/public/mocks/mockBadZoneSubmit.json new file mode 100644 index 0000000000..c314637ebf --- /dev/null +++ b/modules/portal/public/mocks/mockBadZoneSubmit.json @@ -0,0 +1,10 @@ +{ + "name":"vinyl.", + "email":"test@test.com", + "connection":{ + "name":"vinyl.", + "keyName":"vinyl.", + "primaryServer":"foo.2,bar" + }, + "shared":false +} diff --git a/modules/portal/public/mocks/mockEmptyGroupList.json b/modules/portal/public/mocks/mockEmptyGroupList.json new file mode 100644 index 0000000000..d0ad6768b0 --- /dev/null +++ b/modules/portal/public/mocks/mockEmptyGroupList.json @@ -0,0 +1,3 @@ +{ + "groups": [] +} diff --git a/modules/portal/public/mocks/mockGroup.json b/modules/portal/public/mocks/mockGroup.json new file mode 100644 index 0000000000..c057d226dd --- /dev/null +++ b/modules/portal/public/mocks/mockGroup.json @@ -0,0 +1,20 @@ +{ + "name": "vinyl.", + "email": "test@test.com", + "description": "This is a description", + "id": "abc123", + "created": "2016-05-24T18:51:03Z", + "status":"Active", + "memberIds": [ + + "def46hfdb", "kmdfkmsdfk", + "smkfmsdfk" + + ], + "adminUserIds": [ + + "kmdkjmnjs" + + ] + +} \ No newline at end of file diff --git a/modules/portal/public/mocks/mockGroupBadSubmit.json b/modules/portal/public/mocks/mockGroupBadSubmit.json new file mode 100644 index 0000000000..bf29d37193 --- /dev/null +++ b/modules/portal/public/mocks/mockGroupBadSubmit.json @@ -0,0 +1,4 @@ +{ + "name": "vinyl.", + "description": "This is a description" +} \ No newline at end of file diff --git a/modules/portal/public/mocks/mockGroupExistingSubmit.json b/modules/portal/public/mocks/mockGroupExistingSubmit.json new file mode 100644 index 0000000000..4a3570f027 --- /dev/null +++ b/modules/portal/public/mocks/mockGroupExistingSubmit.json @@ -0,0 +1,5 @@ +{ + "name": "vinyl.", + "email": "test@test.com", + "description": "This is a description" +} diff --git a/modules/portal/public/mocks/mockGroupGetMemberList.json b/modules/portal/public/mocks/mockGroupGetMemberList.json new file mode 100644 index 0000000000..defccc559c --- /dev/null +++ b/modules/portal/public/mocks/mockGroupGetMemberList.json @@ -0,0 +1,17 @@ +{ + "members": [ + { + "userName": "foo123", + "firstName": "foo", + "lastName": "bar", + "email": "test@test.com", + "created": "2016-05-24T18:51:03Z", + "id": "abc123" + } + ], + + "startFrom": 1, + "nextId": 5, + "maxItems": 5 + +} diff --git a/modules/portal/public/mocks/mockGroupList.json b/modules/portal/public/mocks/mockGroupList.json new file mode 100644 index 0000000000..4e008c06be --- /dev/null +++ b/modules/portal/public/mocks/mockGroupList.json @@ -0,0 +1,7 @@ +{ + "groups": [ "vinyl.", "Example", "GroupName" ], + "groupName": "vinyl.", + "startFrom": 1, + "nextId": 5, + "maxItems": 100 +} diff --git a/modules/portal/public/mocks/mockGroupListChanges.json b/modules/portal/public/mocks/mockGroupListChanges.json new file mode 100644 index 0000000000..d22f409682 --- /dev/null +++ b/modules/portal/public/mocks/mockGroupListChanges.json @@ -0,0 +1,31 @@ +{ + "changes": [ + { + "id": "abc123", + "changeType": "UPDATE", + "userId": "abc123", + "created": "2016-05-24T18:51:03Z", + "group": { + "id": "abc123", + "name": "Vinyl", + "email": "test@test.com", + "created": "2016-05-24T18:51:03Z", + "status": "ACTIVE", + "members": [ { "id": "ok"} ], + "admins": [ { "id": "ok"} ] + }, + "oldVersion": { + "id": "abc123", + "name": "Vinyl", + "email": "test@test.com", + "created": "2016-05-24T18:51:03Z", + "status": "ACTIVE", + "members": [ { "id": "ok"} ], + "admins": [ { "id": "ok"} ] + }, + "startFrom": 1, + "nextId": 5, + "maxItems": 100 + } + ] +} diff --git a/modules/portal/public/mocks/mockGroupSubmit.json b/modules/portal/public/mocks/mockGroupSubmit.json new file mode 100644 index 0000000000..670ecf9720 --- /dev/null +++ b/modules/portal/public/mocks/mockGroupSubmit.json @@ -0,0 +1,6 @@ +{ + "name": "vinyl.", + "email": "test@test.com", + "description": "This is a description" +} + diff --git a/modules/portal/public/mocks/mockHistory.json b/modules/portal/public/mocks/mockHistory.json new file mode 100644 index 0000000000..8b2281a817 --- /dev/null +++ b/modules/portal/public/mocks/mockHistory.json @@ -0,0 +1,79 @@ +{ + "zoneId":"60e909b7-0249-4896-aa79-26c5741447ec", + "zoneChanges":[ + { + "zone":{ + "name":"vinyl.", + "email":"test@test.com", + "status":"Active", + "created":"2016-05-27T14:52:37Z", + "id":"60e909b7-0249-4896-aa79-26c5741447ec", + "connection":{ + "name":"vinyl.", + "keyName":"vinyl.", + "key":"OBF:1:WRtc40FQwe4AABAAZPvlVbSXHoII/1Vjs8kfBnTJGIsYD/VLa1xM74mOaNvDrJWkkW4q0g==", + "primaryServer":"172.17.0.3" + }, + "transferConnection":{ + "name":"vinyl.", + "keyName":"vinyl.", + "key":"nzisn+4G2ldMn0q1CV3vsg==", + "primaryServer":"172.17.0.3" + }, + "account":"testacct", + "shared":false + }, + "userId":"lcori200", + "changeType":"Create", + "status":"Synced", + "created":"2016-05-27T14:52:37Z", + "id":"c9516a15-513b-4ca8-a079-4b7782970b72" + } + ], + "recordSetChanges":[ + { + "zone":{ + "name":"vinyl.", + "email":"test@test.com", + "status":"Active", + "created":"2016-05-27T14:52:37Z", + "id":"60e909b7-0249-4896-aa79-26c5741447ec", + "connection":{ + "name":"vinyl.", + "keyName":"vinyl.", + "key":"OBF:1:WRtc40FQwe4AABAAZPvlVbSXHoII/1Vjs8kfBnTJGIsYD/VLa1xM74mOaNvDrJWkkW4q0g==", + "primaryServer":"172.17.0.3" + }, + "transferConnection":{ + "name":"vinyl.", + "keyName":"vinyl.", + "key":"nzisn+4G2ldMn0q1CV3vsg==", + "primaryServer":"172.17.0.3" + }, + "account":"testacct", + "shared":false + }, + "recordSet":{ + "type":"A", + "zoneId":"60e909b7-0249-4896-aa79-26c5741447ec", + "name":"arecord", + "ttl":300, + "status":"Active", + "created":"2016-05-27T14:52:56Z", + "updated":"2016-05-27T14:52:56Z", + "records":[ + { + "address":"10.2.2.2" + } + ], + "id":"516d19de-1a05-44d8-9074-0c5c02308e7e", + "account":"testacct" + }, + "userId":"lcori200", + "changeType":"Create", + "status":"Complete", + "created":"2016-05-27T14:52:56Z", + "id":"8315647c-d954-4338-9320-a6c174471704" + } + ] +} diff --git a/modules/portal/public/mocks/mockUserData.json b/modules/portal/public/mocks/mockUserData.json new file mode 100644 index 0000000000..3a9c45cb95 --- /dev/null +++ b/modules/portal/public/mocks/mockUserData.json @@ -0,0 +1,8 @@ +{ + "userName": "foo123", + "firstName": "foo", + "lastName": "bar", + "email": "test@test.com", + "created": "2016-05-24T18:51:03Z", + "id": "abc123" +} diff --git a/modules/portal/public/mocks/mockZone.json b/modules/portal/public/mocks/mockZone.json new file mode 100644 index 0000000000..3c9bb20d8f --- /dev/null +++ b/modules/portal/public/mocks/mockZone.json @@ -0,0 +1,33 @@ +{"zones":[{"name":"vinyl.", + "email":"test@test.com", + "status":"Active", + "created":"2016-05-24T18:16:33Z", + "id":"bf744806-fa76-4b5d-ac7d-bf5d974112c5", + "connection":{"name":"vinyl.", + "keyName":"vinyl.", + "key":"OBF:1:7pw/DVFUaXMAABAAqs2Yassw7UYKIJKOxYPb94GNcPbHL9Ia2Cg3YtrQ1NtA9+G6qF3YFg==", + "primaryServer":"172.17.0.3"}, + "transferConnection":{"name":"vinyl.", + "keyName":"vinyl.", + "key":"nzisn+4G2ldMn0q1CV3vsg==", + "primaryServer":"172.17.0.3"}, + "account":"testacct", + "shared":false}, + {"name":"vinyl2.", + "email":"test@test.com", + "status":"Active", + "created":"2017-11-09T12:49:33Z", + "id":"bf744806-fa76-4b5d-ac7d-bf5d974112c6", + "connection":{"name":"vinyl2.", + "keyName":"vinyl2.", + "key":"OBF:1:7pw/DVFUaXMAABAAqs2Yassw7UYKIJKOxYPb94GNcPbHL9Ia2Cg3YtrQ1NtA9+G6qF3YFh==", + "primaryServer":"172.17.0.4"}, + "transferConnection":{"name":"vinyl2.", + "keyName":"vinyl2.", + "key":"nzisn+4G2ldMn0q1CV3vsh==", + "primaryServer":"172.17.0.4"}, + "account":"testacct2", + "shared":false} + ], + "maxItems": 100 +} diff --git a/modules/portal/public/mocks/mockZoneSubmit.json b/modules/portal/public/mocks/mockZoneSubmit.json new file mode 100644 index 0000000000..9b52cd9cef --- /dev/null +++ b/modules/portal/public/mocks/mockZoneSubmit.json @@ -0,0 +1,11 @@ +{ + "name":"vinyl.", + "email":"test@test.com", + "connection":{ + "name":"vinyl.", + "keyName":"vinyl.", + "key":"abc123", + "primaryServer":"1.2.3.4" + }, + "shared":false +} diff --git a/modules/portal/public/templates/modal-body.html b/modules/portal/public/templates/modal-body.html new file mode 100644 index 0000000000..1e9186c4bd --- /dev/null +++ b/modules/portal/public/templates/modal-body.html @@ -0,0 +1,3 @@ + diff --git a/modules/portal/public/templates/modal-element.html b/modules/portal/public/templates/modal-element.html new file mode 100644 index 0000000000..9d0795a311 --- /dev/null +++ b/modules/portal/public/templates/modal-element.html @@ -0,0 +1,6 @@ +
+ +
+
+
+
diff --git a/modules/portal/public/templates/modal-footer.html b/modules/portal/public/templates/modal-footer.html new file mode 100644 index 0000000000..9b0a005bf4 --- /dev/null +++ b/modules/portal/public/templates/modal-footer.html @@ -0,0 +1 @@ + diff --git a/modules/portal/public/templates/modal-invalid.html b/modules/portal/public/templates/modal-invalid.html new file mode 100644 index 0000000000..93be3e0a4a --- /dev/null +++ b/modules/portal/public/templates/modal-invalid.html @@ -0,0 +1 @@ + diff --git a/modules/portal/public/templates/modal.html b/modules/portal/public/templates/modal.html new file mode 100644 index 0000000000..747fd0d48a --- /dev/null +++ b/modules/portal/public/templates/modal.html @@ -0,0 +1,12 @@ + diff --git a/modules/portal/public/templates/notification.html b/modules/portal/public/templates/notification.html new file mode 100644 index 0000000000..0655cacbb5 --- /dev/null +++ b/modules/portal/public/templates/notification.html @@ -0,0 +1,5 @@ + diff --git a/modules/portal/public/templates/record-modal.html b/modules/portal/public/templates/record-modal.html new file mode 100644 index 0000000000..95f7afc68d --- /dev/null +++ b/modules/portal/public/templates/record-modal.html @@ -0,0 +1,347 @@ +
+ + + + + + + + + + Record name is required + + + + + + + Please enter a minimum value of 30 + + + + + + + At least one IPv4 Address is required + + + + + + + + A fully qualified domain name is required + + + + + + + + Must specify target FQDN for CNAME + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+ + + + + + + + + At least one IPv6 Address is required + + + + + + + + Record text is required + + + + + + + At least one host name or IP address is required + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + +
+ + + + + Must supply at least one target FQDN for NS + + + +
+ + + + + + + + + + + + + + + + + Are you sure you want to update this record?  + + + + + + Are you sure you want to delete this record?  + + + + + + + + +
+
diff --git a/modules/portal/public/templates/zoneconnection-modal.html b/modules/portal/public/templates/zoneconnection-modal.html new file mode 100644 index 0000000000..4dcb5669aa --- /dev/null +++ b/modules/portal/public/templates/zoneconnection-modal.html @@ -0,0 +1,103 @@ +
+ + +

+ Use this form to connect to an already existing zone. If you need a new zone, please contact DNS Ops. Be sure to + inform them that you are managing your zone through Vinyl, and that you need the DNS server to connect to, along + with the TSIG key and key name. +

+
+ + + Name for the DNS zone, for example "vinyldns.example.net.". + + + + + The email distribution list for the zone. Typically the distribution email for the org that owns + the zone. + + + + + + The Vinyl Group that will be given admin rights to the zone. All users in the admin group + will have the ability to manage all + records in the zone, as well as change zone level information + and access rules.
You can create a new group from the Groups page. + + + +
+

DNS Server (Optional)

+
+
+ + + + + The name of the key used to sign requests to the DNS server. This is set when the zone + is setup in the DNS server, and is used to connect to the DNS server when performing + updates and transfers. + + + + + + The secret key used to sign requests sent to the DNS server. + + + + + + The IP Address or host name of the backing DNS server. This host will + be the target for DNS updates. If the port is not specified, port 53 + is assumed. If the port to connect to is different than 53, then include + the port when specifying the DNS Server. For example: bind-01.sys.example.net:5300 + would be used to connect to server bind-01.sys.example.net on port 5300 + + + +
+

Zone Transfer Server (Optional)

+
+
+ + + + + The name of the key used to sign requests to the DNS server. This is set when the zone + is setup in the DNS server, and is used to connect to the DNS server when performing + updates and transfers. + + + + + + The secret key used to sign requests sent to the DNS server. + + + + + + The IP Address or host name of the backing DNS server for zone transfers. This host will + be the target for syncing zones with Vinyl. If the port is not specified, port 53 + is assumed. If the port to connect to is different than 53, then include + the port when specifying the DNS Server. For example: bind-01.sys.example.net:5300 + would be used to connect to server bind-01.sys.example.net on port 5300 + + + + + + + + + +
diff --git a/modules/portal/run_all_tests.sh b/modules/portal/run_all_tests.sh new file mode 100755 index 0000000000..808512f0a5 --- /dev/null +++ b/modules/portal/run_all_tests.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +function check_for() { + which $1 >/dev/null 2>&1 + EXIT_CODE=$? + if [ ${EXIT_CODE} != 0 ] + then + echo "$1 is not installed" + exit ${EXIT_CODE} + fi +} + +check_for python +check_for npm + +# if the program exits before this has been captured then there must have been an error +EXIT_CODE=1 + +cd $(dirname $0) + +# javascript code generate +bower install +grunt default + +TEST_SUITES=('sbt clean coverage test' + 'grunt unit' + ) + +for TEST in "${TEST_SUITES[@]}" +do + echo "##### Running test: [$TEST]" + $TEST + EXIT_CODE=$? + echo "##### Test [$TEST] ended with status [$EXIT_CODE]" + if [ ${EXIT_CODE} != 0 ] + then + exit ${EXIT_CODE} + fi +done diff --git a/modules/portal/test/controllers/FrontendControllerSpec.scala b/modules/portal/test/controllers/FrontendControllerSpec.scala new file mode 100644 index 0000000000..dce7939dd0 --- /dev/null +++ b/modules/portal/test/controllers/FrontendControllerSpec.scala @@ -0,0 +1,258 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import org.junit.runner.RunWith +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification +import org.specs2.runner.JUnitRunner +import play.api.test.Helpers._ +import play.api.test._ +import play.api.inject.guice.GuiceApplicationBuilder +@RunWith(classOf[JUnitRunner]) +class FrontendControllerSpec extends Specification with Mockito { + + // this has to be lazy due to how the FrontendController is boot strapped + def app = GuiceApplicationBuilder().build() + + "FrontendController" should { + "send 404 on a bad request" in new WithApplication(app) { + route(app, FakeRequest(GET, "/boum")) must beSome.which(status(_) == NOT_FOUND) + } + + "Get for '/'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the index page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain(s"Are you sure you want to log out") + } + } + + "Get for '/index'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/index")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the zone page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/index") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Are you sure you want to log out") + contentAsString(result) must contain("Zones | VinylDNS") + } + } + + "Get for '/groups'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/groups")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the groups view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/groups") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Groups | VinylDNS") + } + } + + "Get for '/groups/id'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/groups/some-id")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the groups view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/groups/some-id") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Group | VinylDNS") + } + } + + "Get for '/zones'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/zones")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the zone view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/zones") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Zones | VinylDNS") + } + } + + "Get for '/zones/id'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/zones/some-id")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the groups view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/zones/some-id") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Zone | VinylDNS") + } + } + + "Get for login" should { + "render the login page when the user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/login")).get + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Username") + contentAsString(result) must contain("Password") + } + "redirect to the index page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/login") + .withSession(("username", username))).get + status(result) must beEqualTo(SEE_OTHER) + headers(result) must contain("Location" -> "/index") + } + } + + "Get for logout" should { + "redirect to the login page" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/logout")).get + + status(result) must beEqualTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "clear the session cookie" in new WithApplication(app) { + // TODO: cookie behavior is radically different in play 2.6, so we cannot look for a Set-Cookie header + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/logout") + .withSession(("username", username))).get + headers(result) must contain("Location" -> "/login") + } + } + + "Get for '/batchchanges'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/zones")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the batch changes view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/batchchanges") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Batch Changes | VinylDNS") + } + } + + "Get for '/batchchanges/id'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/batchchanges/some-id")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the batch change view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/batchchanges/some-id") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("Batch Change | VinylDNS") + } + } + + "Get for '/batchchanges/new'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/batchchanges/new")).get + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login") + } + "render the batch change view page when the user is logged in" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/batchchanges/new") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("New Batch Change | VinylDNS") + } + } + + "CustomLinks" should { + "be displayed on login screen if login screen flag is true" in new WithApplication(app) { + val result = route(app, FakeRequest(GET, "/login")).get + contentType(result) must beSome.which(_ == "text/html") + status(result) must equalTo(OK) + contentAsString(result) must contain("test link login") + contentAsString(result) must not(contain("test link sidebar")) + } + + "be displayed on the logged-in view if sidebar flag is true" in new WithApplication(app) { + val username = "LoggedInUser" + val result = route( + app, + FakeRequest(GET, "/zones") + .withSession("username" -> username)).get + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("test link sidebar") + contentAsString(result) must not(contain("test link login")) + } + } + } +} diff --git a/modules/portal/test/controllers/LdapAuthenticatorSpec.scala b/modules/portal/test/controllers/LdapAuthenticatorSpec.scala new file mode 100644 index 0000000000..b0e42563ef --- /dev/null +++ b/modules/portal/test/controllers/LdapAuthenticatorSpec.scala @@ -0,0 +1,440 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import javax.naming.NamingEnumeration +import javax.naming.directory._ + +import controllers.LdapAuthenticator.{ContextCreator, LdapByDomainAuthenticator} +import org.specs2.mock.Mockito +import org.specs2.mock.mockito.ArgumentCapture +import org.specs2.mutable.Specification +import play.api.test.WithApplication +import play.api.inject.guice.GuiceApplicationBuilder + +import scala.reflect.ClassTag +import scala.util.{Failure, Success} + +class LdapAuthenticatorSpec extends Specification with Mockito { + + case class Mocks( + contextCreator: ContextCreator, + context: DirContext, + searchResults: NamingEnumeration[SearchResult], + searchNext: SearchResult, + byDomainAuthenticator: LdapByDomainAuthenticator, + attributes: Attributes) + + /** + * creates a container holding all mocks necessary to create + * @return + */ + def createMocks: Mocks = { + val contextCreator: ContextCreator = mock[ContextCreator] + val context = mock[DirContext] + contextCreator.apply(anyString, anyString).returns(Success(context)) + val searchResults = mock[NamingEnumeration[SearchResult]] + searchResults.hasMore.returns(true) + val mockAttribute = mock[Attribute] + mockAttribute.get().returns("") + val mockAttributes = mock[Attributes] + mockAttributes.get(anyString).returns(mockAttribute) + val searchNext = mock[SearchResult] + searchResults.next.returns(searchNext) + searchNext.getNameInNamespace.returns("") + searchNext.getAttributes.returns(mockAttributes) + context.search(anyString, anyString, any[SearchControls]).returns(searchResults) + val byDomainAuthenticator = new LdapByDomainAuthenticator(Settings, contextCreator) + + Mocks(contextCreator, context, searchResults, searchNext, byDomainAuthenticator, mockAttributes) + } + + val app = GuiceApplicationBuilder().build() + val testApp = GuiceApplicationBuilder().configure(Map("portal.test_login" -> true)).build() + val settings = new Settings(app.configuration) + val testDomain1 = LdapSearchDomain("someDomain", "DC=test,DC=test,DC=com") + val testDomain2 = LdapSearchDomain("anotherDomain", "DC=test,DC=com") + + "LdapAuthenticator" should { + "apply method must create an LDAP Authenticator" in new WithApplication(app) { + val underTest = LdapAuthenticator.apply(settings) + underTest must haveClass( + ClassTag( + new LdapAuthenticator( + settings.ldapSearchBase, + LdapByDomainAuthenticator.apply(), + mock[ServiceAccount]).getClass)) + } + "apply method must create a Test Authenticator if selected" in new WithApplication(testApp) { + val underTest = LdapAuthenticator.apply(new Settings(app.configuration)) + underTest must beAnInstanceOf[TestAuthenticator] + } + ".authenticate" should { + "authenticate first with cable" in { + val byDomainAuthenticator = mock[LdapByDomainAuthenticator] + byDomainAuthenticator + .authenticate(testDomain1, "foo", "bar") + .returns(Success(UserDetails("", "", None, None, None))) + val authenticator = + new LdapAuthenticator(List(testDomain1), byDomainAuthenticator, mock[ServiceAccount]) + val response = authenticator.authenticate("foo", "bar") + + there.was(one(byDomainAuthenticator).authenticate(testDomain1, "foo", "bar")) + there.was(no(byDomainAuthenticator).authenticate(testDomain2, "foo", "bar")) + + "and return a Success if authenticated" in { + response must beASuccessfulTry + } + } + + "authenticate with corphq if cable fails" in { + val byDomainAuthenticator = mock[LdapByDomainAuthenticator] + byDomainAuthenticator + .authenticate(testDomain1, "foo", "bar") + .returns(Failure(new RuntimeException("cable failed"))) + byDomainAuthenticator + .authenticate(testDomain2, "foo", "bar") + .returns(Success(UserDetails("", "", None, None, None))) + val authenticator = new LdapAuthenticator( + List(testDomain1, testDomain2), + byDomainAuthenticator, + mock[ServiceAccount]) + + val response = authenticator.authenticate("foo", "bar") + + there.was(one(byDomainAuthenticator).authenticate(testDomain1, "foo", "bar")) + there.was(one(byDomainAuthenticator).authenticate(testDomain2, "foo", "bar")) + + "and return a Success if authenticated" in { + response must beASuccessfulTry + } + } + + "return error message if both domains fail" in { + val byDomainAuthenticator = mock[LdapByDomainAuthenticator] + byDomainAuthenticator + .authenticate(testDomain1, "foo", "bar") + .returns(Failure(new RuntimeException("cable failed"))) + byDomainAuthenticator + .authenticate(testDomain2, "foo", "bar") + .returns(Failure(new RuntimeException("corphq failed"))) + val authenticator = new LdapAuthenticator( + List(testDomain1, testDomain2), + byDomainAuthenticator, + mock[ServiceAccount]) + + val response = authenticator.authenticate("foo", "bar") + + there.was(one(byDomainAuthenticator).authenticate(testDomain1, "foo", "bar")) + there.was(one(byDomainAuthenticator).authenticate(testDomain2, "foo", "bar")) + + "and return error message" in { + response must beAFailedTry + } + } + } + ".lookup" should { + "lookup first with cable" in { + val byDomainAuthenticator = mock[LdapByDomainAuthenticator] + val serviceAccount = ServiceAccount("cable", "foo", "bar") + byDomainAuthenticator + .lookup(testDomain1, "foo", serviceAccount) + .returns(Success(UserDetails("", "", None, None, None))) + val authenticator = + new LdapAuthenticator( + List(testDomain1, testDomain2), + byDomainAuthenticator, + serviceAccount) + + val response = authenticator.lookup("foo") + + there.was(one(byDomainAuthenticator).lookup(testDomain1, "foo", serviceAccount)) + there.was(no(byDomainAuthenticator).authenticate(testDomain2, "foo", "bar")) + + "and return details if authenticated" in { + response must beASuccessfulTry + } + } + + "lookup with corphq if cable fails" in { + val byDomainAuthenticator = mock[LdapByDomainAuthenticator] + val serviceAccount = mock[ServiceAccount] + byDomainAuthenticator + .lookup(testDomain1, "foo", serviceAccount) + .returns(Failure(new RuntimeException("cable failed"))) + byDomainAuthenticator + .lookup(testDomain2, "foo", serviceAccount) + .returns(Success(UserDetails("", "", None, None, None))) + val authenticator = + new LdapAuthenticator( + List(testDomain1, testDomain2), + byDomainAuthenticator, + serviceAccount) + + val response = authenticator.lookup("foo") + + there.was(one(byDomainAuthenticator).lookup(testDomain1, "foo", serviceAccount)) + there.was(one(byDomainAuthenticator).lookup(testDomain2, "foo", serviceAccount)) + + "and return None if authenticated" in { + response must beASuccessfulTry + } + } + + "return error message if both domains fail" in { + val byDomainAuthenticator = mock[LdapByDomainAuthenticator] + val serviceAccount = mock[ServiceAccount] + byDomainAuthenticator + .lookup(testDomain1, "foo", serviceAccount) + .returns(Failure(new RuntimeException("cable failed"))) + byDomainAuthenticator + .lookup(testDomain2, "foo", serviceAccount) + .returns(Failure(new RuntimeException("corphq failed"))) + val authenticator = + new LdapAuthenticator( + List(testDomain1, testDomain2), + byDomainAuthenticator, + serviceAccount) + + val response = authenticator.lookup("foo") + + there.was(one(byDomainAuthenticator).lookup(testDomain1, "foo", serviceAccount)) + there.was(one(byDomainAuthenticator).lookup(testDomain2, "foo", serviceAccount)) + + "and return error message" in { + response must beAFailedTry + } + } + } + } + + "LdapByDomainAuthenticator" should { + "return an error message if authenticated but no LDAP record is found" in { + val mocks = createMocks + mocks.searchResults.hasMore.returns(false) + val response = mocks.byDomainAuthenticator.authenticate(testDomain1, "foo", "bar") + + response must beAFailedTry + } + + "if creating context succeeds" in { + "and result.hasMore is true" in { + val mocks = createMocks + val response = mocks.byDomainAuthenticator.authenticate(testDomain1, "foo", "bar") + + "return Success" in { + response must beASuccessfulTry + } + + "call contextCreator.apply" in { + there.was(one(mocks.contextCreator).apply("someDomain\\foo", "bar")) + } + + "call the correct search on context" in { + //because specs2 doesn't compare SearchControls objects properly, we have to use argument capture. And since + //mockito only allows all raw variable comparisons or mock variable comparisons, had to use argument + //capture on everything + val baseNameCapture = new ArgumentCapture[String] + val usernameFilterCapture = new ArgumentCapture[String] + val searchControlCapture = new ArgumentCapture[SearchControls] + + there.was( + one(mocks.context).search(baseNameCapture, usernameFilterCapture, searchControlCapture)) + + searchControlCapture.value.getSearchScope mustEqual 2 + baseNameCapture.value mustEqual "DC=test,DC=test,DC=com" + usernameFilterCapture.value mustEqual "(sAMAccountName=foo)" + } + + "call result.hasMore" in { + there.was(one(mocks.searchResults).hasMore) + } + + "call result.next" in { + there.was(one(mocks.searchResults).next()) + } + + "call result.next.getNameInNamespace" in { + there.was(one(mocks.searchNext).getNameInNamespace) + } + + "call result.next.getAttributes" in { + there.was(one(mocks.searchNext).getAttributes) + } + + "call attributes.get for username, email, firstname and lastname" in { + there.was(one(mocks.attributes).get("sAMAccountName")) + there.was(one(mocks.attributes).get("mail")) + there.was(one(mocks.attributes).get("givenName")) + there.was(one(mocks.attributes).get("sn")) + } + } + + "and result.hasMore is false" in { + val mocks = createMocks + mocks.searchResults.hasMore.returns(false) + val response = mocks.byDomainAuthenticator.authenticate(testDomain1, "foo", "bar") + + "return a UserDoesNotExistException" in { + response must beAFailedTry.withThrowable[UserDoesNotExistException] + } + } + } + + "if creating the context fails" in { + val mocks = createMocks + mocks.contextCreator + .apply(anyString, anyString) + .returns(Failure(new RuntimeException("oops"))) + val response = mocks.byDomainAuthenticator.authenticate(testDomain1, "foo", "bar") + + "return an error" in { + response must beAFailedTry + } + } + "lookup a user" should { + "return a user it can find" in { + val mocks = createMocks + val serviceAccount = ServiceAccount("corphq", "serviceuser", "servicepass") + val response = mocks.byDomainAuthenticator.lookup(testDomain1, "foo", serviceAccount) + + response must beASuccessfulTry + "call contextCreator.apply" in { + there.was(one(mocks.contextCreator).apply("corphq\\serviceuser", "servicepass")) + } + + "call the correct search on context" in { + //because specs2 doesn't compare SearchControls objects properly, we have to use argument capture. And since + //mockito only allows all raw variable comparisons or mock variable comparisons, had to use argument + //capture on everything + val baseNameCapture = new ArgumentCapture[String] + val usernameFilterCapture = new ArgumentCapture[String] + val searchControlCapture = new ArgumentCapture[SearchControls] + + there.was( + one(mocks.context).search(baseNameCapture, usernameFilterCapture, searchControlCapture)) + + searchControlCapture.value.getSearchScope mustEqual 2 + baseNameCapture.value mustEqual "DC=test,DC=test,DC=com" + usernameFilterCapture.value mustEqual "(sAMAccountName=foo)" + } + + "call result.hasMore" in { + there.was(one(mocks.searchResults).hasMore) + } + + "call result.next" in { + there.was(one(mocks.searchResults).next()) + } + + "call result.next.getNameInNamespace" in { + there.was(one(mocks.searchNext).getNameInNamespace) + } + + "call result.next.getAttributes" in { + there.was(one(mocks.searchNext).getAttributes) + } + + "call attributes.get for username, email, firstname and lastname" in { + there.was(one(mocks.attributes).get("sAMAccountName")) + there.was(one(mocks.attributes).get("mail")) + there.was(one(mocks.attributes).get("givenName")) + there.was(one(mocks.attributes).get("sn")) + } + } + "return a Failure if the user does not exist" in { + val mocks = createMocks + val serviceAccount = ServiceAccount("cable", "foo", "bar") + mocks.searchResults.hasMore.returns(false) + val response = mocks.byDomainAuthenticator.lookup(testDomain1, "foo", serviceAccount) + + response must beAFailedTry + } + "return a Failure when the service user credentials are incorrect" in { + val mocks = createMocks + val serviceAccount = ServiceAccount("cable", "foo", "bar") + mocks.contextCreator + .apply(anyString, anyString) + .returns(Failure(new RuntimeException("oops"))) + + val response = mocks.byDomainAuthenticator.lookup(testDomain1, "foo", serviceAccount) + + response must beAFailedTry + } + } + } + "TestAuthenticator" should { + "authenticate the test user" in { + val mockLdapAuth = mock[LdapAuthenticator] + mockLdapAuth + .authenticate(anyString, anyString) + .returns(Failure(new UserDoesNotExistException("should not be here"))) + + val underTest = new TestAuthenticator(mockLdapAuth) + val result = underTest.authenticate("testuser", "testpassword") + + result must beASuccessfulTry( + UserDetails( + "O=test,OU=testdata,CN=testuser", + "testuser", + Some("test@test.test"), + Some("Test"), + Some("User"))) + there.were(noCallsTo(mockLdapAuth)) + } + "authenticate a user that is not the test user" in { + val mockLdapAuth = mock[LdapAuthenticator] + val userDetails = UserDetails("o=foo,cn=bar", "foo", Some("bar"), Some("baz"), Some("qux")) + mockLdapAuth.authenticate(anyString, anyString).returns(Success(userDetails)) + + val underTest = new TestAuthenticator(mockLdapAuth) + val result = underTest.authenticate("foo", "bar") + + result must beASuccessfulTry(userDetails) + there.was(one(mockLdapAuth).authenticate("foo", "bar")) + } + "lookup the test user" in { + val mockLdapAuth = mock[LdapAuthenticator] + mockLdapAuth + .authenticate(anyString, anyString) + .returns(Failure(new UserDoesNotExistException("should not be here"))) + + val underTest = new TestAuthenticator(mockLdapAuth) + val result = underTest.lookup("testuser") + + result must beASuccessfulTry( + UserDetails( + "O=test,OU=testdata,CN=testuser", + "testuser", + Some("test@test.test"), + Some("Test"), + Some("User"))) + there.were(noCallsTo(mockLdapAuth)) + } + "lookup a user that is not the test user" in { + val mockLdapAuth = mock[LdapAuthenticator] + val userDetails = UserDetails("o=foo,cn=bar", "foo", Some("bar"), Some("baz"), Some("qux")) + mockLdapAuth.lookup(anyString).returns(Success(userDetails)) + + val underTest = new TestAuthenticator(mockLdapAuth) + val result = underTest.lookup("foo") + + result must beASuccessfulTry(userDetails) + there.was(one(mockLdapAuth).lookup("foo")) + } + } +} diff --git a/modules/portal/test/controllers/UserAccountAccessorSpec.scala b/modules/portal/test/controllers/UserAccountAccessorSpec.scala new file mode 100644 index 0000000000..c7a55a856b --- /dev/null +++ b/modules/portal/test/controllers/UserAccountAccessorSpec.scala @@ -0,0 +1,172 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import models.UserAccount +import org.joda.time.DateTime +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification + +import scala.util.{Failure, Success} + +class UserAccountAccessorSpec extends Specification with Mockito { + "User Account Accessor" should { + "Return the user when storing a user that does not exist already" in { + val mockStore = mock[UserAccountStore] + val user = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + mockStore.storeUser(any[UserAccount]).returns(Success(user)) + + val underTest = new UserAccountAccessor(mockStore) + underTest.put(user) must beASuccessfulTry(user) + } + + "Return the new user when storing a user that already exists in the store" in { + val mockStore = mock[UserAccountStore] + val oldUser = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + val newUser = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "new-key", + "new-secret") + mockStore.storeUser(any[UserAccount]).returns(Success(newUser)) + + val underTest = new UserAccountAccessor(mockStore) + underTest.put(newUser) must beASuccessfulTry(newUser) + } + + "Return the failure when something goes wrong while storing a user" in { + val mockStore = mock[UserAccountStore] + val ex = new IllegalArgumentException("foobar") + mockStore.storeUser(any[UserAccount]).returns(Failure(ex)) + val user = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + + val underTest = new UserAccountAccessor(mockStore) + underTest.put(user) must beAFailedTry(ex) + } + + "Return the user when retrieving a user that exists by name" in { + val mockStore = mock[UserAccountStore] + val user = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + mockStore.getUserByName(user.username).returns(Success(Some(user))) + mockStore.getUserById(user.username).returns(Success(None)) + + val underTest = new UserAccountAccessor(mockStore) + underTest.get("fbaggins") must beASuccessfulTry[Option[UserAccount]](Some(user)) + } + + "Return the user when retrieving a user that exists by user id" in { + val mockStore = mock[UserAccountStore] + val user = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + mockStore.getUserByName(user.userId).returns(Success(None)) + mockStore.getUserById(user.userId).returns(Success(Some(user))) + + val underTest = new UserAccountAccessor(mockStore) + underTest.get("uuid") must beASuccessfulTry[Option[UserAccount]](Some(user)) + } + + "Return None when the user to be retrieved does not exist" in { + val mockStore = mock[UserAccountStore] + mockStore.getUserByName(any[String]).returns(Success(None)) + mockStore.getUserById(any[String]).returns(Success(None)) + + val underTest = new UserAccountAccessor(mockStore) + underTest.get("fbaggins") must beASuccessfulTry[Option[UserAccount]](None) + } + + "Return the failure when the user cannot be looked up via user name" in { + val mockStore = mock[UserAccountStore] + val user = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + val ex = new IllegalArgumentException("foobar") + mockStore.getUserByName(user.username).returns(Failure(ex)) + mockStore.getUserById(user.username).returns(Success(None)) + + val underTest = new UserAccountAccessor(mockStore) + underTest.get("fbaggins") must beAFailedTry(ex) + } + + "Return the failure when the user cannot be looked up via user id" in { + val mockStore = mock[UserAccountStore] + val user = new UserAccount( + "uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now(), + "key", + "secret") + val ex = new IllegalArgumentException("foobar") + mockStore.getUserByName(user.userId).returns(Success(None)) + mockStore.getUserById(user.userId).returns(Failure(ex)) + + val underTest = new UserAccountAccessor(mockStore) + underTest.get("uuid") must beAFailedTry(ex) + } + } +} diff --git a/modules/portal/test/controllers/VinylDNSSpec.scala b/modules/portal/test/controllers/VinylDNSSpec.scala new file mode 100644 index 0000000000..49a01e06dd --- /dev/null +++ b/modules/portal/test/controllers/VinylDNSSpec.scala @@ -0,0 +1,1440 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import com.amazonaws.auth.BasicAWSCredentials +import models.UserAccount +import org.joda.time.DateTime +import org.junit.runner._ +import org.specs2.mock.Mockito +import org.specs2.mutable._ +import org.specs2.runner._ +import play.api.{Application, Configuration, Environment, Mode} +import play.api.libs.json.{JsObject, JsValue, Json, OWrites} +import play.api.libs.ws.WSClient +import play.api.mvc._ +import play.api.test.Helpers._ +import play.api.test._ +import play.core.server.{Server, ServerConfig} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +/* these verbs are renamed to avoid collisions with the verb identifiers in the standard values library file */ +import play.api.routing.sird.{ + DELETE => backendDELETE, + GET => backendGET, + POST => backendPOST, + PUT => backendPUT, + _ +} +import play.api.inject.guice.GuiceApplicationBuilder + +import scala.util.{Failure, Success} + +/** + * Add your spec here. + * You can mock out a whole application including requests, plugins etc. + * For more information, consult the wiki. + */ +@RunWith(classOf[JUnitRunner]) +class VinylDNSSpec extends Specification with Mockito { + + val simulatedBackendPort = 9001 + + // Important: order is critical, put the override after loading the default config from application-test.conf + val testConfig: Configuration = + Configuration.load(Environment.simple()) ++ Configuration.from( + Map("portal.vinyldns.backend.url" -> s"http://localhost:$simulatedBackendPort")) + + val app: Application = GuiceApplicationBuilder() + .configure(testConfig) + .build() + + val components: ControllerComponents = Helpers.stubControllerComponents() + val defaultActionBuilder = DefaultActionBuilder(Helpers.stubBodyParser()) + + "VinylDNS" should { + "send 404 on a bad request" in new WithApplication(app) { + route(app, FakeRequest(GET, "/boum")) must beSome.which(status(_) == NOT_FOUND) + } + + ".getUserData" should { + "return the current logged in users information" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(anyString).returns(Success(Some(frodoAccount))) + userAccessor.get("frodo").returns(Success(Some(frodoAccount))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val result = vinyldnsPortal + .getAuthenticatedUserData() + .apply( + FakeRequest(GET, "/api/users/currentuser").withSession(("username", "frodo")) + ) + + status(result) must beEqualTo(200) + val userInfo: JsValue = contentAsJson(result) + + (userInfo \ "id").as[String] must beEqualTo(frodoAccount.userId) + (userInfo \ "userName").as[String] must beEqualTo(frodoAccount.username) + Some((userInfo \ "firstName").as[String]) must beEqualTo(frodoAccount.firstName) + Some((userInfo \ "lastName").as[String]) must beEqualTo(frodoAccount.lastName) + Some((userInfo \ "email").as[String]) must beEqualTo(frodoAccount.email) + (userInfo \ "isSuper").as[Boolean] must beFalse + } + "return Not found if the current logged in user was not found" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(anyString).returns(Success(Some(frodoAccount))) + userAccessor.get("frodo").returns(Success(None)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val result = vinyldnsPortal + .getAuthenticatedUserData() + .apply( + FakeRequest(GET, "/api/users/currentuser").withSession(("username", "frodo")) + ) + + status(result) must beEqualTo(404) + } + } + + ".login" should { + "if login is correct with a valid key" should { + "call the authenticator and the account accessor" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(frodoDetails.username).returns(Success(Some(frodoAccount))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + there.was(one(authenticator).authenticate("frodo", "secondbreakfast")) + there.was(one(userAccessor).get("frodo")) + } + "call the new user account accessor and return the new style account" in new WithApplication( + app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate(frodoDetails.username, "secondbreakfast") + .returns(Success(frodoDetails)) + userAccessor.get(frodoDetails.username).returns(Success(Some(frodoAccount))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest().withFormUrlEncodedBody( + "username" -> frodoDetails.username, + "password" -> "secondbreakfast") + ) + there.was(one(authenticator).authenticate("frodo", "secondbreakfast")) + there.was(one(userAccessor).get("frodo")) + } + "call the user accessor to create the new user account if it is not found" in new WithApplication( + app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(frodoAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + there.was(one(userAccessor).get("frodo")) + there.was(one(userAccessor).put(_: UserAccount)) + } + "call the user accessor to create the new style user account if it is not found" in new WithApplication( + app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + + authenticator + .authenticate(frodoDetails.username, "secondbreakfast") + .returns(Success(frodoDetails)) + userAccessor.get(frodoDetails.username).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(frodoAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest().withFormUrlEncodedBody( + "username" -> frodoDetails.username, + "password" -> "secondbreakfast") + ) + there.was(one(userAccessor).get(frodoDetails.username)) + there.was(one(userAccessor).put(_: UserAccount)) + } + + "do not call the user accessor to create the new user account if it is found" in new WithApplication( + app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = buildMockUserAccountAccessor + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(any[String]).returns(Success(Some(frodoAccount))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + there.was(one(userAccessor).get("frodo")) + there.was(no(userAccessor).put(_: UserAccount)) + } + + "set the username, and key for the new style membership" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate(frodoDetails.username, "secondbreakfast") + .returns(Success(frodoDetails)) + userAccessor.get(frodoDetails.username).returns(Success(Some(frodoAccount))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest().withFormUrlEncodedBody( + "username" -> frodoDetails.username, + "password" -> "secondbreakfast") + ) + session(response).get("username") must beSome(frodoAccount.username) + session(response).get("accessKey") must beSome(frodoAccount.accessKey) + session(response).get("rootAccount") must beNone + + } + "redirect to index using the new style accounts" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate(frodoDetails.username, "secondbreakfast") + .returns(Success(frodoDetails)) + userAccessor.get(frodoDetails.username).returns(Success(Some(frodoAccount))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest().withFormUrlEncodedBody( + "username" -> frodoDetails.username, + "password" -> "secondbreakfast") + ) + status(response) mustEqual 303 + redirectLocation(response) must beSome("/index") + } + } + + "if login is correct but no account is found" should { + "call the authenticator and the user account accessor" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(frodoAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + there.was(one(authenticator).authenticate("frodo", "secondbreakfast")) + there.was(one(userAccessor).get("frodo")) + } + "set the username and the key" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(frodoAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + session(response).get("username") must beSome(frodoAccount.username) + session(response).get("accessKey") must beSome(frodoAccount.accessKey) + } + "redirect to index" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("frodo", "secondbreakfast").returns(Success(frodoDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(frodoAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + status(response) mustEqual 303 + redirectLocation(response) must beSome("/index") + } + } + + "if service account login is correct but no account is found" should { + "call the authenticator and the user account accessor" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("service", "password").returns(Success(serviceAccountDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(serviceAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "service", "password" -> "password") + ) + there.was(one(authenticator).authenticate("service", "password")) + there.was(one(userAccessor).get("service")) + } + "set the username and the key" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("service", "password").returns(Success(serviceAccountDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(serviceAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "service", "password" -> "password") + ) + session(response).get("username") must beSome(serviceAccount.username) + session(response).get("accessKey") must beSome(serviceAccount.accessKey) + } + "redirect to index" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator.authenticate("service", "password").returns(Success(serviceAccountDetails)) + userAccessor.get(anyString).returns(Success(None)) + userAccessor.put(any[UserAccount]).returns(Success(serviceAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "service", "password" -> "password") + ) + status(response) mustEqual 303 + redirectLocation(response) must beSome("/index") + } + } + + "if login is not correct" should { + "call the authenticator not the account accessor" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate("frodo", "secondbreakfast") + .returns(Failure(new RuntimeException("login failed"))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + there.was(one(authenticator).authenticate("frodo", "secondbreakfast")) + there.was(no(userAccessor).get(anyString)) + } + "do not set the username and key" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate("frodo", "secondbreakfast") + .returns(Failure(new RuntimeException("login failed"))) + + val vinyldnsPortal = + new VinylDNS( + config, + authenticator, + mockUserAccountAccessor, + mockAuditLog, + ws, + components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + session(response).get("username") must beNone + session(response).get("accessKey") must beNone + } + "redirect to login" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate("frodo", "secondbreakfast") + .returns(Failure(new RuntimeException("login failed"))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + status(response) mustEqual 303 + redirectLocation(response) must beSome("/login") + } + "set the flash with an error message" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + authenticator + .authenticate("frodo", "secondbreakfast") + .returns(Failure(new RuntimeException("login failed"))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val response = vinyldnsPortal + .login() + .apply( + FakeRequest() + .withFormUrlEncodedBody("username" -> "frodo", "password" -> "secondbreakfast") + ) + flash(response).get("alertType") must beSome("danger") + flash(response).get("alertMessage") must beSome("Authentication failed, please try again") + } + } + } + + ".newGroup" should { + "return the group description on create - status ok (200)" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPOST(p"/groups") => + defaultActionBuilder { + Results.Ok(hobbitGroup) + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.newGroup()( + FakeRequest(POST, "/groups") + .withJsonBody(hobbitGroupRequest) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(OK) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + contentAsJson(result) must beEqualTo(hobbitGroup) + } + } + } + "return bad request (400) if the request is not properly made" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPOST(p"/groups") => + defaultActionBuilder { + Results.BadRequest("user id not found") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.newGroup()( + FakeRequest(POST, "/groups") + .withJsonBody(invalidHobbitGroup) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(BAD_REQUEST) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return authentication failed (401) when auth fails in the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPOST(p"/groups") => + defaultActionBuilder { + Results.Unauthorized("Invalid credentials") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.newGroup()( + FakeRequest(POST, s"/groups") + .withJsonBody(hobbitGroupRequest) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(UNAUTHORIZED) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return conflict (409) when the group exists already" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPOST(p"/groups") => + defaultActionBuilder { + Results.Conflict("A group named 'hobbits' already exists") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.newGroup()( + FakeRequest(POST, "/groups") + .withJsonBody(hobbitGroupRequest) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(CONFLICT) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + } + ".getGroup" should { + "return the group description if it is found - status ok (200)" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/${hobbitGroupId}") => + defaultActionBuilder { + Results.Ok(hobbitGroup) + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getGroup(hobbitGroupId)( + FakeRequest(GET, s"/groups/$hobbitGroupId") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(OK) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + contentAsJson(result) must beEqualTo(hobbitGroup) + } + } + } + "return authentication failed (401) when auth fails in the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.Unauthorized("Invalid credentials") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getGroup(hobbitGroupId)( + FakeRequest(GET, s"/groups/$hobbitGroupId") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(UNAUTHORIZED) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return a not found (404) if the group does not exist" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/not-hobbits") => + defaultActionBuilder { + Results.NotFound + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getGroup("not-hobbits")( + FakeRequest(GET, "/groups/not-hobbits") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(NOT_FOUND) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + } + ".deleteGroup" should { + "return ok with no content (204) when delete is successful" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendDELETE(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.NoContent + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.deleteGroup(hobbitGroupId)( + FakeRequest(DELETE, s"/groups/$hobbitGroupId") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(NO_CONTENT) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return authentication failed (401) when authentication fails in the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendDELETE(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.Unauthorized("Invalid credentials") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.deleteGroup(hobbitGroupId)( + FakeRequest(DELETE, s"/groups/$hobbitGroupId") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(UNAUTHORIZED) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return forbidden (403) when authorization fails in the backend" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendDELETE(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.Forbidden("You do not have access to delete this group") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.deleteGroup(hobbitGroupId)( + FakeRequest(DELETE, s"/groups/$hobbitGroupId") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(FORBIDDEN) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return a not found (404) if the group does not exist" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendDELETE(p"/groups/not-hobbits") => + defaultActionBuilder { + Results.NotFound + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.deleteGroup("not-hobbits")( + FakeRequest(DELETE, "/groups/not-hobbits") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(NOT_FOUND) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + } + ".updateGroup" should { + "return the new group description if it is saved successfully - Ok (200)" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPUT(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.Ok(hobbitGroup) + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.updateGroup(hobbitGroupId)( + FakeRequest(PUT, s"/groups/$hobbitGroupId") + .withJsonBody(hobbitGroup) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(OK) + contentAsJson(result) must beEqualTo(hobbitGroup) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return bad request (400) when the request is rejected by the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPUT(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.BadRequest("Unknown user") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.updateGroup(hobbitGroupId)( + FakeRequest(PUT, s"/groups/$hobbitGroupId") + .withJsonBody(invalidHobbitGroup) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(BAD_REQUEST) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return unauthorized (401) when request fails authentication" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPUT(p"/groups/$hobbitGroupId") => + defaultActionBuilder { + Results.Unauthorized("Authentication failed, bad signature") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.updateGroup(hobbitGroupId)( + FakeRequest(PUT, s"/groups/$hobbitGroupId") + .withJsonBody(hobbitGroup) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(UNAUTHORIZED) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return forbidden (403) when request fails permissions in the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPUT(p"/groups/${hobbitGroupId}") => + defaultActionBuilder { + Results.Forbidden("Authentication failed, bad signature") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.updateGroup(hobbitGroupId)( + FakeRequest(PUT, s"/groups/$hobbitGroupId") + .withJsonBody(hobbitGroup) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(FORBIDDEN) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return not found (404) when the group is not found in the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendPUT(p"/groups/not-hobbits") => + defaultActionBuilder { + Results.NotFound + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.updateGroup("not-hobbits")( + FakeRequest(PUT, "/groups/not-hobbits") + .withJsonBody(hobbitGroup) + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(NOT_FOUND) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + } + ".getMemberList" should { + "return a list of members of the group when requested - Ok (200)" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/$hobbitGroupId/members") => + defaultActionBuilder { + Results.Ok(hobbitGroupMembers) + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getMemberList(hobbitGroupId)( + FakeRequest(GET, s"/data/groups/$hobbitGroupId/members") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(OK) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + contentAsJson(result) must beEqualTo(hobbitGroupMembers) + } + } + } + "return bad request (400) when the request is rejected by the back end" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/$hobbitGroupId/members") => + defaultActionBuilder { + Results.BadRequest("Invalid maxItems") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getMemberList(hobbitGroupId)( + FakeRequest(GET, s"/groups/$hobbitGroupId/members?maxItems=0") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(BAD_REQUEST) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return unauthorized (401) when request fails authentication" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/$hobbitGroupId/members") => + defaultActionBuilder { + Results.Unauthorized("The supplied authentication is invalid") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getMemberList(hobbitGroupId)( + FakeRequest(GET, s"/groups/$hobbitGroupId/members") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(UNAUTHORIZED) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + "return not found (404) when the group is not found in the backend" in new WithApplication( + app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups/$hobbitGroupId/members") => + defaultActionBuilder { + Results.NotFound + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getMemberList(hobbitGroupId)( + FakeRequest(GET, s"/groups/$hobbitGroupId/members") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(NOT_FOUND) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + } + ".myGroups" should { + "return the list of groups when requested - Ok(200)" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups") => + defaultActionBuilder { + Results.Ok(frodoGroupList) + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getMyGroups()( + FakeRequest(GET, s"/api/groups") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(OK) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + contentAsJson(result) must beEqualTo(frodoGroupList) + } + } + } + "return unauthorized (401) when request fails authentication" in new WithApplication(app) { + Server.withRouter(ServerConfig(port = Some(simulatedBackendPort), mode = Mode.Test)) { + case backendGET(p"/groups") => + defaultActionBuilder { + Results.Unauthorized("The supplied authentication is invalid") + } + } { implicit port => + WsTestClient.withClient { client => + val mockUserAccessor = mock[UserAccountAccessor] + mockUserAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + testConfig, + mockLdapAuthenticator, + mockUserAccessor, + mockAuditLog, + client, + components) + val result = underTest.getMyGroups()( + FakeRequest(GET, s"/api/groups") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + + status(result) must beEqualTo(UNAUTHORIZED) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + } + } + } + } + ".getUserCreds" should { + "return credentials for a user using the new style" in new WithApplication(app) { + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + mockUserAccountAccessor.getUserByKey(anyString).returns(Success(Some(frodoAccount))) + val underTest = new VinylDNS( + config, + mockLdapAuthenticator, + mockUserAccountAccessor, + mockAuditLog, + ws, + components) + + val result: BasicAWSCredentials = underTest.getUserCreds(Some(frodoAccount.accessKey)) + there.was(one(mockUserAccountAccessor).getUserByKey(frodoAccount.accessKey)) + result.getAWSAccessKeyId must beEqualTo(frodoAccount.accessKey) + result.getAWSSecretKey must beEqualTo(frodoAccount.accessSecret) + } + "fail when not supplied with a key using the new style" in new WithApplication(app) { + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + val underTest = new VinylDNS( + config, + mockLdapAuthenticator, + mockUserAccountAccessor, + mockAuditLog, + ws, + components) + + underTest.getUserCreds(None) must throwAn[IllegalArgumentException] + } + } + ".serveCredFile" should { + "return a csv file with the new style credentials" in new WithApplication(app) { + import play.api.mvc.Result + + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + userAccessor.get(frodoAccount.username).returns(Success(Some(frodoAccount))) + val underTest = + new VinylDNS(config, mockLdapAuthenticator, userAccessor, mockAuditLog, ws, components) + + val result: Future[Result] = underTest.serveCredsFile("credsfile.csv")( + FakeRequest(GET, s"/download-creds-file/credsfile.csv") + .withSession( + "username" -> frodoAccount.username, + "accessKey" -> frodoAccount.accessKey)) + val content: String = contentAsString(result) + content must contain("NT ID") + content must contain(frodoAccount.username) + content must contain(frodoAccount.accessKey) + content must contain(frodoAccount.accessSecret) + } + } + + ".lookupUserAccount" should { + implicit val userInfoWrites: OWrites[VinylDNS.UserInfo] = Json.writes[VinylDNS.UserInfo] + + "return a list of users from a list of usernames" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + val lookupValue = "someNTID" + authenticator.lookup(lookupValue).returns(Success(frodoDetails)) + userAccessor.get(frodoDetails.username).returns(Success(Some(frodoAccount))) + + val expected = Json.toJson(VinylDNS.UserInfo.fromAccount(frodoAccount)) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val result = vinyldnsPortal + .getUserDataByUsername(lookupValue) + .apply( + FakeRequest(GET, s"/api/users/lookupuser/$lookupValue") + .withSession("username" -> "frodo") + ) + status(result) must beEqualTo(200) + header("Pragma", result) must beSome("no-cache") + header("Cache-Control", result) must beSome("no-cache, no-store, must-revalidate") + header("Expires", result) must beSome("0") + contentAsJson(result) must beEqualTo(expected) + } + + "return a 404 if the account is not found" in new WithApplication(app) { + val authenticator: LdapAuthenticator = mock[LdapAuthenticator] + val userAccessor: UserAccountAccessor = mock[UserAccountAccessor] + val config: Configuration = Configuration.load(Environment.simple()) + val ws: WSClient = mock[WSClient] + userAccessor.get(frodoAccount.username).returns(Success(None)) + authenticator + .lookup(frodoAccount.username) + .returns(Failure(new UserDoesNotExistException("not found"))) + + val vinyldnsPortal = + new VinylDNS(config, authenticator, userAccessor, mockAuditLog, ws, components) + val result = vinyldnsPortal + .getUserDataByUsername(frodoAccount.username) + .apply( + FakeRequest(GET, s"/api/users/lookupuser/${frodoAccount.username}") + .withSession("username" -> "frodo") + ) + + status(result) must beEqualTo(404) + header("Pragma", result) must beNone + header("Cache-Control", result) must beNone + header("Expires", result) must beNone + } + } + } + + val frodoDetails = UserDetails( + "CN=frodo,OU=hobbits,DC=middle,DC=earth", + "frodo", + Some("fbaggins@hobbitmail.me"), + Some("Frodo"), + Some("Baggins")) + val frodoAccount = UserAccount( + "frodo-uuid", + "fbaggins", + Some("Frodo"), + Some("Baggins"), + Some("fbaggins@hobbitmail.me"), + DateTime.now, + "key", + "secret") + + val serviceAccountDetails = + UserDetails("CN=frodo,OU=hobbits,DC=middle,DC=earth", "service", None, None, None) + val serviceAccount = + UserAccount("service-uuid", "service", None, None, None, DateTime.now, "key", "secret") + + val frodoJsonString: String = s"""{ + | "userName": "${frodoAccount.username}", + | "firstName": "${frodoAccount.firstName}", + | "lastName": "${frodoAccount.lastName}", + | "email": "${frodoAccount.email}", + | "created": "${frodoAccount.created}", + | "id": "${frodoAccount.userId}" + |} + """.stripMargin + + val samAccount = UserAccount( + "sam-uuid", + "sgamgee", + Some("Samwise"), + Some("Gamgee"), + Some("sgamgee@hobbitmail.me"), + DateTime.now, + "key", + "secret") + val samDetails = UserDetails( + "CN=sam,OU=hobbits,DC=middle,DC=earth", + "sam", + Some("sgamgee@hobbitmail.me"), + Some("Sam"), + Some("Gamgee")) + + def buildMockUserAccountAccessor: UserAccountAccessor = { + val accessor = mock[UserAccountAccessor] + accessor.get(anyString).returns(Success(Some(mock[UserAccount]))) + accessor.put(any[UserAccount]).returns(Success(mock[UserAccount])) + accessor.getUserByKey(anyString).returns(Success(Some(mock[UserAccount]))) + accessor + } + + def buildMockChangeLogStore: ChangeLogStore = { + val log = mock[ChangeLogStore] + log.log(any[ChangeLogMessage]).defaultAnswer { + case message: ChangeLogMessage => Success(message) + } + log + } + + val mockUserAccountAccessor: UserAccountAccessor = buildMockUserAccountAccessor + + val mockAuditLog: ChangeLogStore = buildMockChangeLogStore + + val mockLdapAuthenticator: LdapAuthenticator = mock[LdapAuthenticator] + + val frodoJson: String = + s"""{ + |"name": "${frodoAccount.username}" + |} + """.stripMargin + + val hobbitGroupId = "uuid-12345-abcdef" + val hobbitGroup: JsValue = Json.parse(s"""{ + | "id": "${hobbitGroupId}", + | "name": "hobbits", + | "email": "hobbitAdmin@shire.me", + | "description": "Hobbits of the shire", + | "members": [ { "id": "${frodoAccount.userId}" }, { "id": "samwise-userId" } ], + | "admins": [ { "id": "${frodoAccount.userId}" } ] + | } + """.stripMargin) + + val ringbearerGroup: JsValue = Json.parse( + s"""{ + | "id": "ringbearer-group-uuid", + | "name": "ringbearers", + | "email": "future-minions@mordor.me", + | "description": "Corruptable folk of middle-earth", + | "members": [ { "id": "${frodoAccount.userId}" }, { "id": "sauron-userId" } ], + | "admins": [ { "id": "sauron-userId" } ] + | } + """.stripMargin + ) + val hobbitGroupRequest: JsValue = Json.parse(s"""{ + | "name": "hobbits", + | "email": "hobbitAdmin@shire.me", + | "description": "Hobbits of the shire", + | "members": [ { "id": "${frodoAccount.userId}" }, { "id": "samwise-userId" } ], + | "admins": [ { "id": "${frodoAccount.userId}" } ] + | } + """.stripMargin) + + val invalidHobbitGroup: JsValue = Json.parse(s"""{ + | "name": "hobbits", + | "email": "hobbitAdmin@shire.me", + | "description": "Hobbits of the shire", + | "members": [ { "id": "${frodoAccount.userId}" }, { "id": "merlin-userId" } ], + | "admins": [ { "id": "${frodoAccount.userId}" } ] + | } + """.stripMargin) + + val hobbitGroupMembers: JsValue = Json.parse( + s"""{ + | "members": [ ${frodoJsonString} ], + | "maxItems": 100 + |} + """.stripMargin + ) + + val groupList: JsObject = Json.obj("groups" -> Json.arr(hobbitGroup)) + val emptyGroupList: JsObject = Json.obj("groups" -> Json.arr()) + + val frodoGroupList: JsObject = Json.obj("groups" -> Json.arr(hobbitGroup, ringbearerGroup)) +} diff --git a/modules/portal/test/controllers/datastores/DynamoDBChangeLogStoreSpec.scala b/modules/portal/test/controllers/datastores/DynamoDBChangeLogStoreSpec.scala new file mode 100644 index 0000000000..d20786b27f --- /dev/null +++ b/modules/portal/test/controllers/datastores/DynamoDBChangeLogStoreSpec.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient +import controllers.{ChangeLogMessage, Create, UserChangeMessage} +import models.UserAccount +import org.joda.time.DateTime +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification +import play.api.{Configuration, Environment} +import vinyldns.core.crypto.CryptoAlgebra + +class DynamoDBChangeLogStoreSpec extends Specification with Mockito { + private val testCrypto = new CryptoAlgebra { + def encrypt(value: String): String = "encrypted!" + def decrypt(value: String): String = "decrypted!" + } + + private val testUserAcc = UserAccount("foo", Some("bar"), Some("baz"), Some("qux")) + private val testMessage = + UserChangeMessage("foo", "bar", DateTime.now, Create, testUserAcc, Some(testUserAcc)) + "DynamoDbChangeLogStore" should { + "accept a message and return it upon success" in { + val (client, config) = buildMocks() + val underTest = new DynamoDBChangeLogStore(client, config, testCrypto) + val result = underTest.log(testMessage) + result must beSuccessfulTry[ChangeLogMessage](testMessage) + } + + "accept a message and return it upon success when email, last name, and first name are none" in { + val (client, config) = buildMocks() + val underTest = new DynamoDBChangeLogStore(client, config, testCrypto) + val user = testUserAcc.copy(firstName = None, lastName = None, email = None) + val message = testMessage.copy(previousUser = None) + + val result = underTest.log(message) + result must beSuccessfulTry[ChangeLogMessage](message) + } + } + + def buildMocks(): (AmazonDynamoDBClient, Configuration) = { + val client = mock[AmazonDynamoDBClient] + val config = Configuration.load(Environment.simple()) + (client, config) + } + + def buildTestStore( + client: AmazonDynamoDBClient = mock[AmazonDynamoDBClient], + config: Configuration = mock[Configuration]): DynamoDBUserAccountStore = + new DynamoDBUserAccountStore(client, config, testCrypto) +} diff --git a/modules/portal/test/controllers/datastores/DynamoDBUserAccountStoreSpec.scala b/modules/portal/test/controllers/datastores/DynamoDBUserAccountStoreSpec.scala new file mode 100644 index 0000000000..0a1c08e5dc --- /dev/null +++ b/modules/portal/test/controllers/datastores/DynamoDBUserAccountStoreSpec.scala @@ -0,0 +1,229 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import java.util + +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient +import com.amazonaws.services.dynamodbv2.model._ +import models.UserAccount +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification +import play.api.{Configuration, Environment} +import vinyldns.core.crypto.CryptoAlgebra + +class DynamoDBUserAccountStoreSpec extends Specification with Mockito { + + val testCrypto = new CryptoAlgebra { + def encrypt(value: String): String = "encrypted!" + def decrypt(value: String): String = "decrypted!" + } + + "DynamoDBUserAccountStore" should { + "Store a new user when email, first name, and last name are None" in { + val (client, config) = buildMocks() + val user = UserAccount("fbaggins", None, None, None) + val item = DynamoDBUserAccountStore.toItem(user, testCrypto) + val mockResult = mock[PutItemResult] + mockResult.getAttributes.returns(item) + client.putItem(any[PutItemRequest]).returns(mockResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.storeUser(user) + result must beASuccessfulTry + compareUserAccounts(result.get, user) + } + + "Store a new user when everything is ok" in { + val (client, config) = buildMocks() + val user = + UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fbaggins@hobbitmail.me")) + val item = DynamoDBUserAccountStore.toItem(user, testCrypto) + val mockResult = mock[PutItemResult] + mockResult.getAttributes.returns(item) + client.putItem(any[PutItemRequest]).returns(mockResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.storeUser(user) + result must beASuccessfulTry + compareUserAccounts(result.get, user) + } + + "Store a user over an existing user returning the new user" in { + val (client, config) = buildMocks() + val oldUser = UserAccount("old", Some("Old"), Some("User"), Some("oldman@mail.me")) + val newUser = oldUser.copy(username = "new") + val mockResult = mock[PutItemResult] + mockResult.getAttributes.returns(DynamoDBUserAccountStore.toItem(newUser, testCrypto)) + client.putItem(any[PutItemRequest]).returns(mockResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + underTest.storeUser(oldUser) + val result = underTest.storeUser(newUser) + result must beASuccessfulTry + compareUserAccounts(result.get, newUser) + } + + "Retrieve a given user based on user-id" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val (client, config) = buildMocks() + val getResult = mock[GetItemResult] + val resultItem = DynamoDBUserAccountStore.toItem(user, testCrypto) + getResult.getItem.returns(resultItem) + client.getItem(any[GetItemRequest]).returns(getResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.getUserById(user.userId) + result must beASuccessfulTry + result.get must beSome + compareUserAccounts(result.get.get, user) + } + + "Retrieve a given user based on username" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val (client, config) = buildMocks() + val queryResult = mock[QueryResult] + val resultList = new util.ArrayList[util.Map[String, AttributeValue]]() + resultList.add(DynamoDBUserAccountStore.toItem(user, testCrypto)) + queryResult.getItems.returns(resultList) + queryResult.getCount.returns(1) + client.query(any[QueryRequest]).returns(queryResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.getUserByName(user.username) + result must beASuccessfulTry + result.get must beSome + compareUserAccounts(result.get.get, user) + } + + "Return a successful none if the user is not found by id (empty item)" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val (client, config) = buildMocks() + val getResult = mock[GetItemResult] + val resultItem = new util.HashMap[String, AttributeValue]() + getResult.getItem.returns(resultItem) + client.getItem(any[GetItemRequest]).returns(getResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.getUserById(user.userId) + result must beASuccessfulTry[Option[UserAccount]](None) + } + + "Return a successful none if the user is not found by id (null)" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val (client, config) = buildMocks() + val getResult = null + client.getItem(any[GetItemRequest]).returns(getResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.getUserById(user.userId) + result must beASuccessfulTry[Option[UserAccount]](None) + } + + "Return a successful none if the user is not found by name" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val (client, config) = buildMocks() + val queryResult = mock[QueryResult] + val resultList = new util.ArrayList[util.Map[String, AttributeValue]]() + queryResult.getItems.returns(resultList) + queryResult.getCount.returns(0) + client.query(any[QueryRequest]).returns(queryResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.getUserByName(user.username) + result must beASuccessfulTry(None) + } + + "Return a user based on username when more than one is found" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val secondUser = + UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val thirdUser = + UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val (client, config) = buildMocks() + val queryResult = mock[QueryResult] + val resultList = new util.ArrayList[util.Map[String, AttributeValue]]() + resultList.add(DynamoDBUserAccountStore.toItem(user, testCrypto)) + resultList.add(DynamoDBUserAccountStore.toItem(secondUser, testCrypto)) + resultList.add(DynamoDBUserAccountStore.toItem(thirdUser, testCrypto)) + queryResult.getItems.returns(resultList) + queryResult.getCount.returns(3) + client.query(any[QueryRequest]).returns(queryResult) + + val underTest = new DynamoDBUserAccountStore(client, config, testCrypto) + + val result = underTest.getUserByName(user.username) + result must beASuccessfulTry + result.get must beSome + compareUserAccounts(result.get.get, user) + } + + "Encrypt the user secret" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val mockCrypto = mock[CryptoAlgebra] + mockCrypto.encrypt(user.accessSecret).returns("hello") + + val item = DynamoDBUserAccountStore.toItem(user, mockCrypto) + item.get("secretkey").getS must beEqualTo("hello") + + there.was(one(mockCrypto).encrypt(user.accessSecret)) + } + + "Decrypt the user secret" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val mockCrypto = mock[CryptoAlgebra] + mockCrypto.encrypt(user.accessSecret).returns("encrypt") + mockCrypto.decrypt("encrypt").returns("decrypt") + + val item = DynamoDBUserAccountStore.toItem(user, mockCrypto) + val u = DynamoDBUserAccountStore.fromItem(item, mockCrypto) + u.accessSecret must beEqualTo("decrypt") + + there.was(one(mockCrypto).decrypt(item.get("secretkey").getS)) + } + } + + def buildMocks(): (AmazonDynamoDBClient, Configuration) = { + val client = mock[AmazonDynamoDBClient] + val config = Configuration.load(Environment.simple()) + (client, config) + } + + def buildTestStore( + client: AmazonDynamoDBClient = mock[AmazonDynamoDBClient], + config: Configuration = mock[Configuration]): DynamoDBUserAccountStore = + new DynamoDBUserAccountStore(client, config, testCrypto) + + def compareUserAccounts(actual: UserAccount, expected: UserAccount) = { + actual.userId must beEqualTo(expected.userId) + actual.created.compareTo(expected.created) must beEqualTo(0) + actual.username must beEqualTo(expected.username) + actual.firstName must beEqualTo(expected.firstName) + actual.lastName must beEqualTo(expected.lastName) + actual.accessKey must beEqualTo(expected.accessKey) + testCrypto.decrypt(actual.accessSecret) must beEqualTo( + testCrypto.decrypt(expected.accessSecret)) + } +} diff --git a/modules/portal/test/controllers/datastores/InMemoryChangeLogStoreSpec.scala b/modules/portal/test/controllers/datastores/InMemoryChangeLogStoreSpec.scala new file mode 100644 index 0000000000..7703290747 --- /dev/null +++ b/modules/portal/test/controllers/datastores/InMemoryChangeLogStoreSpec.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import controllers.{ChangeLogMessage, Create, UserChangeMessage} +import models.UserAccount +import org.joda.time.DateTime +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification + +class InMemoryChangeLogStoreSpec extends Specification with Mockito { + "InMemoryChangeLogStore" should { + "accept a message and return it upon success" in { + val underTest = new InMemoryChangeLogStore + val userAcc = UserAccount("foo", "bar", None, None, None, DateTime.now, "ak", "sk") + val message = UserChangeMessage("foo", "bar", DateTime.now, Create, userAcc, None) + + val result = underTest.log(message) + result must beASuccessfulTry[ChangeLogMessage](message) + } + } +} diff --git a/modules/portal/test/controllers/datastores/InMemoryUserAccountStoreSpec.scala b/modules/portal/test/controllers/datastores/InMemoryUserAccountStoreSpec.scala new file mode 100644 index 0000000000..a09d1be23f --- /dev/null +++ b/modules/portal/test/controllers/datastores/InMemoryUserAccountStoreSpec.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.datastores + +import models.UserAccount +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification + +class InMemoryUserAccountStoreSpec extends Specification with Mockito { + "InMemoryUserAccountStore" should { + "Store a new user when everything is ok" in { + val underTest = new InMemoryUserAccountStore() + val user = mock[UserAccount] + + val result = underTest.storeUser(user) + result must beASuccessfulTry(user) + } + + "Store a user over an existing user returning the new user" in { + val underTest = new InMemoryUserAccountStore() + val oldUser = mock[UserAccount] + oldUser.userId.returns("user") + oldUser.username.returns("old") + val newUser = mock[UserAccount] + newUser.userId.returns("user") + newUser.username.returns("new") + + underTest.storeUser(oldUser) + val result = underTest.storeUser(newUser) + result must beASuccessfulTry(newUser) + result.get.username must beEqualTo("new") + } + + "Retrieve a given user based on user-id" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val underTest = new InMemoryUserAccountStore() + underTest.storeUser(user) + + val result = underTest.getUserById(user.userId) + result must beASuccessfulTry[Option[UserAccount]](Some(user)) + } + + "Retrieve a given user based on username" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val underTest = new InMemoryUserAccountStore() + underTest.storeUser(user) + + val result = underTest.getUserByName(user.username) + result must beASuccessfulTry[Option[UserAccount]](Some(user)) + } + + "Return a successful none if the user is not found by id" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val underTest = new InMemoryUserAccountStore() + + val result = underTest.getUserById(user.userId) + result must beASuccessfulTry[Option[UserAccount]](None) + } + + "Return a successful none if the user is not found by name" in { + val user = UserAccount("fbaggins", Some("Frodo"), Some("Baggins"), Some("fb@hobbitmail.me")) + val underTest = new InMemoryUserAccountStore() + + val result = underTest.getUserByName(user.username) + result must beASuccessfulTry[Option[UserAccount]](None) + } + } +} diff --git a/modules/portal/test/models/CustomLinksSpec.scala b/modules/portal/test/models/CustomLinksSpec.scala new file mode 100644 index 0000000000..3e1e598b5f --- /dev/null +++ b/modules/portal/test/models/CustomLinksSpec.scala @@ -0,0 +1,72 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification +import play.api.Configuration + +class CustomLinksSpec extends Specification with Mockito { + "CustomLinks" should { + "load link from config" in { + val linkOne = CustomLink(false, true, "title 1", "href 1", "icon 1") + val config = Map( + "links" -> List( + Map( + "displayOnSidebar" -> linkOne.displayOnSidebar, + "displayOnLoginScreen" -> linkOne.displayOnLoginScreen, + "title" -> linkOne.title, + "href" -> linkOne.href, + "icon" -> linkOne.icon + ) + ) + ) + val customLinks = CustomLinks(Configuration.from(config)) + customLinks.links must beEqualTo(List[CustomLink](linkOne)) + } + + "load multiple links from config" in { + val linkOne = CustomLink(false, true, "title 1", "href 1", "icon 1") + val linkTwo = CustomLink(true, false, "title 2", "href 2", "icon 2") + val config = Map( + "links" -> List( + Map( + "displayOnSidebar" -> linkOne.displayOnSidebar, + "displayOnLoginScreen" -> linkOne.displayOnLoginScreen, + "title" -> linkOne.title, + "href" -> linkOne.href, + "icon" -> linkOne.icon + ), + Map( + "displayOnSidebar" -> linkTwo.displayOnSidebar, + "displayOnLoginScreen" -> linkTwo.displayOnLoginScreen, + "title" -> linkTwo.title, + "href" -> linkTwo.href, + "icon" -> linkTwo.icon + ), + ) + ) + val customLinks = CustomLinks(Configuration.from(config)) + customLinks.links must beEqualTo(List[CustomLink](linkOne, linkTwo)) + } + + "load empty list if no links in config" in { + val customLinks = CustomLinks(Configuration.from(Map())) + customLinks.links must beEqualTo(List[CustomLink]()) + } + } +} diff --git a/modules/portal/test/models/UserAccountSpec.scala b/modules/portal/test/models/UserAccountSpec.scala new file mode 100644 index 0000000000..c92d4b2863 --- /dev/null +++ b/modules/portal/test/models/UserAccountSpec.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import java.util.UUID + +import org.specs2.mock.Mockito +import org.specs2.mutable.Specification + +class UserAccountSpec extends Specification with Mockito { + "UserAccount" should { + "Create a UserAccount from username, first name, last name and email" in { + val username = "fbaggins" + val fname = Some("Frodo") + val lname = Some("Baggins") + val email = Some("fb@hobbitmail.me") + + val result = UserAccount(username, fname, lname, email) + + result must beAnInstanceOf[UserAccount] + UUID.fromString(result.userId) must beAnInstanceOf[UUID] + result.username must beEqualTo(username) + result.firstName must beEqualTo(fname) + result.lastName must beEqualTo(lname) + result.email must beEqualTo(email) + result.accessKey.length must beEqualTo(20) + result.accessSecret.length must beEqualTo(20) + } + } +} diff --git a/modules/portal/test/models/VinylRequestSpec.scala b/modules/portal/test/models/VinylRequestSpec.scala new file mode 100644 index 0000000000..81d35c33a0 --- /dev/null +++ b/modules/portal/test/models/VinylRequestSpec.scala @@ -0,0 +1,193 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import java.io.{BufferedInputStream, BufferedReader, ByteArrayInputStream, InputStreamReader} +import java.net.URI +import java.util + +import com.amazonaws.HttpMethod +import com.amazonaws.http.HttpMethodName +import org.specs2.mock.Mockito +import org.specs2.mutable._ +import org.specs2.runner._ +import org.junit.runner._ + +import scala.collection.JavaConversions._ + +import play.api.test._ +import play.api.test.Helpers._ + +import scala.util.parsing.input.StreamReader + +@RunWith(classOf[JUnitRunner]) +class VinylDNSRequestSpec extends Specification with Mockito { + "SignableVinylDNSRequest" should { + "have zero headers for a new request" in { + val mockVinylDNSReq = createMockVinylDNSRequest() + + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + underTest.getHeaders.size() must beEqualTo(0) + } + "have more headers after adding a header" in { + val mockVinylDNSReq = createMockVinylDNSRequest() + + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + val originalCount = underTest.getHeaders.size() + underTest.addHeader("foo", "bar") + + underTest.getHeaders.size() must beGreaterThan(originalCount) + } + "contain the added header" in { + val key = "foobar" + val value = "bazqux" + val mockVinylDNSReq = createMockVinylDNSRequest() + + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + underTest.addHeader(key, value) + + underTest.getHeaders.get(key) must beEqualTo(value) + } + "decode the resource path included in the url" in { + val path = "/foo/bar" + val mockVinylDNSReq = createMockVinylDNSRequest(s"http://some.server$path?baz=qux") + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + underTest.getResourcePath must beEqualTo(path) + } + "have zero parameters in a new request" in { + val mockVinylDNSReq = createMockVinylDNSRequest() + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + underTest.getParameters.size must beEqualTo(0) + } + "have more parameters after adding one" in { + val mockVinylDNSReq = createMockVinylDNSRequest() + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + val originalCount = underTest.getParameters.size() + + underTest.addParameter("foo", "bar") + + (underTest.getParameters.size() must be).greaterThan(originalCount) + } + "contain the added parameter" in { + val key = "foobar" + val value = "bazqux" + val mockVinylDNSReq = createMockVinylDNSRequest() + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + underTest.addParameter(key, value) + + underTest.getParameters.get(key).contains(value) must beTrue + } + "add a value to an existing parameter" in { + val key = "foobar" + val value = "qux" + val mockVinylDNSReq = createMockVinylDNSRequest() + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + underTest.addParameter(key, "baz") + + underTest.addParameter(key, value) + + underTest.getParameters.get(key).contains(value) must beTrue + } + "decode the endpoint from the url" in { + val urlString = "https://some.server/foo/bar?baz=qux" + val mockVinylDNSReq = createMockVinylDNSRequest(urlString) + val expected = new URI("https://some.server") + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + underTest.getEndpoint must beEqualTo(expected) + } + "extract the proper http method from the vinyldns request" in { + val values = Seq( + "GET" -> HttpMethodName.GET, + "POST" -> HttpMethodName.POST, + "PUT" -> HttpMethodName.PUT, + "PATCH" -> HttpMethodName.PATCH, + "HEAD" -> HttpMethodName.HEAD, + "DELETE" -> HttpMethodName.DELETE + ) + values.map { + case (in, out) => + val dr = createMockVinylDNSRequest(method = in) + + val underTest = new SignableVinylDNSRequest(dr) + + underTest.getHttpMethod must beEqualTo(out) + } + } + "time offset is always zero" in { + val mockVinylDNSReq = createMockVinylDNSRequest() + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + underTest.getTimeOffset must beEqualTo(0) + } + "content is a stream representation of the body string" in { + val bodyString = "this is the foo content." + val mockVinylDNSReq = createMockVinylDNSRequest(payload = Some(bodyString)) + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + val reader = new BufferedReader(new InputStreamReader(underTest.getContent)) + reader.readLine() must beEqualTo(bodyString) + } + "unwrapped content is a stream representation of the body string" in { + val bodyString = "this is the foo content." + val mockVinylDNSReq = createMockVinylDNSRequest(payload = Some(bodyString)) + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + val reader = new BufferedReader(new InputStreamReader(underTest.getContentUnwrapped)) + reader.readLine() must beEqualTo(bodyString) + } + "read limit is always -1" in { + val mockVinylDNSReq = createMockVinylDNSRequest() + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + underTest.getReadLimitInfo.getReadLimit must beEqualTo(-1) + } + "original request object is returned unmodified" in { + val vinyldnsRequest = + VinylDNSRequest("FOO", "http://some.server.somewhere:9090/path/to/bar", "baz") + val underTest = new SignableVinylDNSRequest(vinyldnsRequest) + + underTest.getOriginalRequestObject must beTheSameAs(vinyldnsRequest) + } + "setting the content changes the input stream to the new content" in { + val originalBodyString = "this is the foo content." + val newBodyString = "this is the bar content." + val mockVinylDNSReq = createMockVinylDNSRequest(payload = Some(originalBodyString)) + val underTest = new SignableVinylDNSRequest(mockVinylDNSReq) + + underTest.setContent(new ByteArrayInputStream(newBodyString.getBytes("UTF-8"))) + + val reader = new BufferedReader(new InputStreamReader(underTest.getContent)) + reader.readLine() must beEqualTo(newBodyString) + } + } + + private def createMockVinylDNSRequest( + url: String = "", + method: String = "GET", + payload: Option[String] = None) = { + val req = mock[VinylDNSRequest] + val uri = new URI(url) + req.url.returns(s"${uri.getScheme}://${uri.getHost}") + req.method.returns(method) + req.payload.returns(payload) + req.path.returns(new URI(url).getPath) + + req + } +} diff --git a/project/CompilerOptions.scala b/project/CompilerOptions.scala new file mode 100644 index 0000000000..e7f0a9fb22 --- /dev/null +++ b/project/CompilerOptions.scala @@ -0,0 +1,39 @@ +import sbt._ + +object CompilerOptions { + + lazy val scalac_2_12_Options = Seq( + "-deprecation", // Emit warning and location for usages of deprecated APIs. + "-encoding", "utf-8", // Specify character encoding used by source files. + "-explaintypes", // Explain type errors in more detail. + "-feature", // Emit warning for usages of features that should be imported explicitly. + "-language:higherKinds", // Allow higher-kinded types + "-unchecked", // Enable additional warnings where generated code depends on assumptions. + "-Xlint:infer-any", // Warn when a type argument is inferred to be `Any`. + "-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id. + "-Xlint:nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'. + "-Xlint:nullary-unit", // Warn when nullary methods return Unit. + "-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field + "-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope. + "-Xlint:unsound-match", // Pattern match may not be typesafe. + "-Ypartial-unification", // Enable partial unification in type constructor inference + "-Ywarn-dead-code", // Warn when dead code is identified. + "-Ywarn-extra-implicit", // Warn when more than one implicit parameter section is defined. + "-Ywarn-inaccessible", // Warn about inaccessible types in method signatures. + "-Ywarn-infer-any", // Warn when a type argument is inferred to be `Any`. + "-Ywarn-unused:implicits", // Warn if an implicit parameter is unused. + "-Ywarn-unused:imports", // Warn if an import selector is not referenced. + "-Ywarn-unused:locals", // Warn if a local definition is unused. + "-Ywarn-unused:params", // Warn if a value parameter is unused. + "-Ywarn-unused:patvars", // Warn if a variable bound in a pattern is unused. + "-Ywarn-unused:privates" // Warn if a private member is unused. + ) + + def scalacOptionsByV(scalaVersion: String): Seq[String] = { + val VersionNumber((major +: minor +: _ +: _), _, _) = scalaVersion + (major, minor) match { + case (2, minV) if minV > 11 ⇒ scalac_2_12_Options + case _ ⇒ sys.error(s"scala version $scalaVersion is not supported.") + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 0000000000..87f0536602 --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,81 @@ +import sbt._ +object Dependencies { + + lazy val akkaHttpV = "10.1.3" + lazy val akkaV = "2.5.12" + lazy val jettyV = "8.1.12.v20130726" + lazy val pureConfigV = "0.9.0" + lazy val metricsScalaV = "3.5.9" + lazy val prometheusV = "0.4.0" + lazy val catsEffectV = "0.10.1" + lazy val configV = "1.3.2" + lazy val scalaTestV = "3.0.4" + lazy val scodecV = "1.1.5" + lazy val playV = "2.6.15" + lazy val awsV = "1.11.95" + + lazy val compileDependencies = Seq( + "com.typesafe.akka" %% "akka-http" % akkaHttpV, + "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpV, + "de.heikoseeberger" %% "akka-http-json4s" % "1.21.0", + "com.typesafe.akka" %% "akka-actor" % akkaV, + "com.typesafe.akka" %% "akka-remote" % akkaV, + "com.typesafe.akka" %% "akka-slf4j" % akkaV, + "ch.qos.logback" % "logback-classic" % "1.0.7", + "com.aaronbedra" % "orchard" % "0.1.1", + "com.amazonaws" % "aws-java-sdk-core" % awsV withSources(), + "com.amazonaws" % "aws-java-sdk-dynamodb" % awsV withSources(), + "com.amazonaws" % "aws-java-sdk-sqs" % awsV withSources(), + "com.github.ben-manes.caffeine" % "caffeine" % "2.2.7", + "com.github.cb372" %% "scalacache-caffeine" % "0.9.4", + "com.google.protobuf" % "protobuf-java" % "2.6.1", + "com.zaxxer" % "HikariCP" % "2.5.1", + "dnsjava" % "dnsjava" % "2.1.7", + "joda-time" % "joda-time" % "2.8.1", + "org.mariadb.jdbc" % "mariadb-java-client" % "2.2.3", + "nl.grons" %% "metrics-scala" % metricsScalaV, + "org.apache.commons" % "commons-lang3" % "3.4", + "org.flywaydb" % "flyway-core" % "5.1.4", + "org.json4s" %% "json4s-ext" % "3.5.3", + "org.json4s" %% "json4s-jackson" % "3.5.3", + "org.scalaz" %% "scalaz-core" % "7.1.16", + "org.scalikejdbc" %% "scalikejdbc" % "2.5.2", + "org.scalikejdbc" %% "scalikejdbc-config" % "2.5.2", + "org.scodec" %% "scodec-bits" % scodecV, + "org.slf4j" % "slf4j-api" % "1.7.7", + "co.fs2" %% "fs2-core" % "0.10.5", + "com.github.pureconfig" %% "pureconfig" % pureConfigV, + "io.prometheus" % "simpleclient_hotspot" % prometheusV, + "io.prometheus" % "simpleclient_dropwizard" % prometheusV, + "io.prometheus" % "simpleclient_common" % prometheusV, + "com.typesafe" % "config" % configV, + "org.typelevel" %% "cats-effect" % catsEffectV + ) + + lazy val coreDependencies = Seq( + "org.typelevel" %% "cats-effect" % catsEffectV, + "com.typesafe" % "config" % configV, + "org.scodec" %% "scodec-bits" % scodecV, + "org.scalatest" %% "scalatest" % scalaTestV % "test" + ) + + lazy val testDependencies = Seq( + "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpV, + "junit" % "junit" % "4.12", + "org.mockito" % "mockito-core" % "1.10.19", + "org.scalatest" %% "scalatest" % scalaTestV, + "org.typelevel" %% "scalaz-scalatest" % "0.5.2", + "org.scalacheck" %% "scalacheck" % "1.13.4", + "com.ironcorelabs" %% "cats-scalatest" % "2.3.1" + ) map (_ % "it, test") + + lazy val portalDependencies = Seq( + "com.typesafe.play" %% "play-json" % "2.6.9", + "com.amazonaws" % "aws-java-sdk-core" % awsV withSources(), + "com.amazonaws" % "aws-java-sdk-dynamodb" % awsV withSources(), + "com.typesafe.play" %% "play-jdbc" % playV, + "com.typesafe.play" %% "play-guice" % playV, + "com.typesafe.play" %% "play-ahc-ws" % playV, + "com.typesafe.play" %% "play-specs2" % playV % "test" + ) +} diff --git a/project/Resolvers.scala b/project/Resolvers.scala new file mode 100644 index 0000000000..21aba3ec98 --- /dev/null +++ b/project/Resolvers.scala @@ -0,0 +1,11 @@ +import sbt._ + +object Resolvers { + + lazy val additionalResolvers = Seq( + "spray" at "http://repo.spray.io/", + "dnvriend at bintray" at "https://dl.bintray.com/dnvriend/maven", + "bintray" at "https://jcenter.bintray.com", + "DynamoDBLocal" at "https://s3-us-west-2.amazonaws.com/dynamodb-local/release", + ) +} diff --git a/project/assembly.sbt b/project/assembly.sbt new file mode 100644 index 0000000000..d95475f16f --- /dev/null +++ b/project/assembly.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.7") diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000000..64cf32f7f9 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.1.4 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000000..db7eefef82 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,37 @@ +logLevel := Level.Warn + +resolvers += "Flyway" at "https://flywaydb.org/repo" + +resolvers += "jgit-repo" at "http://download.eclipse.org/jgit/maven" + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.5.1") + +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") + +addSbtPlugin("com.github.gseitz" % "sbt-protobuf" % "0.6.3") + +addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.0") + +addSbtPlugin("io.github.davidmweber" % "flyway-sbt" % "5.0.0") + +addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.2.1") + +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.5") + +addSbtPlugin("com.tapad" % "sbt-docker-compose" % "1.0.34") + +addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.2") + +addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.8") + +addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.0.0") + +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.15") + +addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.5.1") + +addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") + +addSbtPlugin("com.typesafe.sbt" % "sbt-license-report" % "1.2.0") + +addSbtPlugin("com.47deg" % "sbt-microsites" % "0.7.22") diff --git a/scalastyle-config.xml b/scalastyle-config.xml new file mode 100644 index 0000000000..f7d5ba5d7b --- /dev/null +++ b/scalastyle-config.xml @@ -0,0 +1,79 @@ + + Circe Configuration + + + FOR + IF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + + \ No newline at end of file diff --git a/scalastyle-test-config.xml b/scalastyle-test-config.xml new file mode 100644 index 0000000000..1cad17fb26 --- /dev/null +++ b/scalastyle-test-config.xml @@ -0,0 +1,81 @@ + + Circe Configuration + + + FOR + IF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + + \ No newline at end of file diff --git a/version.sbt b/version.sbt new file mode 100644 index 0000000000..06f50fc037 --- /dev/null +++ b/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1"