View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.configuration.dev;
7   
8   import static org.tailormap.api.persistence.Configuration.HOME_PAGE;
9   import static org.tailormap.api.persistence.Configuration.PORTAL_MENU;
10  import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
11  import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
12  import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMS;
13  import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMTS;
14  import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
15  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.ATTRIBUTE_LIST;
16  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.EXPORT;
17  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.FEATURE_INFO;
18  import static org.tailormap.api.security.AuthorisationService.ACCESS_TYPE_VIEW;
19  
20  import java.io.IOException;
21  import java.lang.invoke.MethodHandles;
22  import java.net.URI;
23  import java.nio.charset.StandardCharsets;
24  import java.sql.SQLException;
25  import java.time.OffsetDateTime;
26  import java.time.ZoneId;
27  import java.util.ArrayList;
28  import java.util.Collection;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.NoSuchElementException;
32  import java.util.Optional;
33  import java.util.Properties;
34  import java.util.Set;
35  import java.util.UUID;
36  import org.apache.solr.client.solrj.SolrServerException;
37  import org.quartz.SchedulerException;
38  import org.slf4j.Logger;
39  import org.slf4j.LoggerFactory;
40  import org.springframework.beans.factory.annotation.Value;
41  import org.springframework.boot.SpringApplication;
42  import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
43  import org.springframework.boot.context.event.ApplicationReadyEvent;
44  import org.springframework.context.ApplicationContext;
45  import org.springframework.context.event.EventListener;
46  import org.springframework.core.io.ClassPathResource;
47  import org.springframework.jdbc.core.simple.JdbcClient;
48  import org.springframework.transaction.annotation.Transactional;
49  import org.springframework.util.PropertyPlaceholderHelper;
50  import org.tailormap.api.admin.model.TaskSchedule;
51  import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
52  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
53  import org.tailormap.api.geotools.featuresources.JDBCFeatureSourceHelper;
54  import org.tailormap.api.geotools.featuresources.WFSFeatureSourceHelper;
55  import org.tailormap.api.persistence.Application;
56  import org.tailormap.api.persistence.Catalog;
57  import org.tailormap.api.persistence.Configuration;
58  import org.tailormap.api.persistence.GeoService;
59  import org.tailormap.api.persistence.Group;
60  import org.tailormap.api.persistence.Page;
61  import org.tailormap.api.persistence.SearchIndex;
62  import org.tailormap.api.persistence.TMFeatureSource;
63  import org.tailormap.api.persistence.TMFeatureType;
64  import org.tailormap.api.persistence.Upload;
65  import org.tailormap.api.persistence.User;
66  import org.tailormap.api.persistence.helper.GeoServiceHelper;
67  import org.tailormap.api.persistence.json.AppContent;
68  import org.tailormap.api.persistence.json.AppLayerSettings;
69  import org.tailormap.api.persistence.json.AppSettings;
70  import org.tailormap.api.persistence.json.AppTreeLayerNode;
71  import org.tailormap.api.persistence.json.AppTreeLevelNode;
72  import org.tailormap.api.persistence.json.AppTreeNode;
73  import org.tailormap.api.persistence.json.AppUiSettings;
74  import org.tailormap.api.persistence.json.AttachmentAttributeType;
75  import org.tailormap.api.persistence.json.AttributeSettings;
76  import org.tailormap.api.persistence.json.AttributeValueSettings;
77  import org.tailormap.api.persistence.json.AuthorizationRule;
78  import org.tailormap.api.persistence.json.AuthorizationRuleDecision;
79  import org.tailormap.api.persistence.json.Bounds;
80  import org.tailormap.api.persistence.json.CatalogNode;
81  import org.tailormap.api.persistence.json.FeatureTypeRef;
82  import org.tailormap.api.persistence.json.FeatureTypeTemplate;
83  import org.tailormap.api.persistence.json.Filter;
84  import org.tailormap.api.persistence.json.FilterEditConfiguration;
85  import org.tailormap.api.persistence.json.FilterGroup;
86  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
87  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
88  import org.tailormap.api.persistence.json.GeoServiceSettings;
89  import org.tailormap.api.persistence.json.JDBCConnectionProperties;
90  import org.tailormap.api.persistence.json.MenuItem;
91  import org.tailormap.api.persistence.json.PageTile;
92  import org.tailormap.api.persistence.json.ServiceAuthentication;
93  import org.tailormap.api.persistence.json.TailormapObjectRef;
94  import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
95  import org.tailormap.api.persistence.json.WMSStyle;
96  import org.tailormap.api.repository.ApplicationRepository;
97  import org.tailormap.api.repository.CatalogRepository;
98  import org.tailormap.api.repository.ConfigurationRepository;
99  import org.tailormap.api.repository.FeatureSourceRepository;
100 import org.tailormap.api.repository.GeoServiceRepository;
101 import org.tailormap.api.repository.GroupRepository;
102 import org.tailormap.api.repository.PageRepository;
103 import org.tailormap.api.repository.SearchIndexRepository;
104 import org.tailormap.api.repository.UploadRepository;
105 import org.tailormap.api.repository.UserRepository;
106 import org.tailormap.api.scheduling.IndexTask;
107 import org.tailormap.api.scheduling.TMJobDataMap;
108 import org.tailormap.api.scheduling.Task;
109 import org.tailormap.api.scheduling.TaskManagerService;
110 import org.tailormap.api.scheduling.TaskType;
111 import org.tailormap.api.security.InternalAdminAuthentication;
112 import org.tailormap.api.solr.SolrHelper;
113 import org.tailormap.api.solr.SolrService;
114 import org.tailormap.api.viewer.model.AppStyling;
115 import org.tailormap.api.viewer.model.Component;
116 import org.tailormap.api.viewer.model.ComponentConfig;
117 import tools.jackson.core.JacksonException;
118 import tools.jackson.databind.json.JsonMapper;
119 
120 /**
121  * Populates entities to add services and applications to demo functionality, support development and use in integration
122  * tests with a common set of test data. See README.md for usage details.
123  */
124 @org.springframework.context.annotation.Configuration
125 @ConditionalOnProperty(name = "tailormap-api.database.populate-testdata", havingValue = "true")
126 public class PopulateTestData {
127 
128   private static final Logger logger =
129       LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
130   private final ApplicationContext appContext;
131   private final UserRepository userRepository;
132   private final GroupRepository groupRepository;
133   private final CatalogRepository catalogRepository;
134   private final GeoServiceRepository geoServiceRepository;
135   private final GeoServiceHelper geoServiceHelper;
136   private final SolrService solrService;
137   private final TaskManagerService taskManagerService;
138   private final FeatureSourceRepository featureSourceRepository;
139   private final ApplicationRepository applicationRepository;
140   private final ConfigurationRepository configurationRepository;
141   private final SearchIndexRepository searchIndexRepository;
142   private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
143   private final UploadRepository uploadRepository;
144   private final PageRepository pageRepository;
145   private final JdbcClient jdbcClient;
146 
147   @Value("${spatial.dbs.connect:false}")
148   private boolean connectToSpatialDbs;
149 
150   @Value("#{'${tailormap-api.database.populate-testdata.categories}'.split(',')}")
151   private Set<String> categories;
152 
153   @Value("${spatial.dbs.localhost:true}")
154   private boolean connectToSpatialDbsAtLocalhost;
155 
156   @Value("${tailormap-api.database.populate-testdata.admin-hashed-password}")
157   private String adminHashedPassword;
158 
159   @Value("${tailormap-api.database.populate-testdata.management-hashed-password}")
160   private String managementHashedPassword;
161 
162   @Value("${tailormap-api.database.populate-testdata.exit:false}")
163   private boolean exit;
164 
165   @Value("${MAP5_URL:#{null}}")
166   private String map5url;
167 
168   private final String tiles3dProxyUsername = "b3p";
169 
170   // Password is for proxying a public service for testing purposes, not really secret
171   private final String tiles3dProxyPassword = "cOxPlJFWmtndFk0eVg5VdL";
172 
173   @Value("${tailormap-api.solr-batch-size:1000}")
174   private int solrBatchSize;
175 
176   @Value("${tailormap-api.solr-geometry-validation-rule:repairBuffer0}")
177   private String solrGeometryValidationRule;
178 
179   private static final String PROVINCIE_FEATURE_TYPE_NAME = "bestuurlijkegebieden:Provinciegebied";
180 
181   public PopulateTestData(
182       ApplicationContext appContext,
183       UserRepository userRepository,
184       GroupRepository groupRepository,
185       CatalogRepository catalogRepository,
186       GeoServiceRepository geoServiceRepository,
187       GeoServiceHelper geoServiceHelper,
188       SolrService solrService,
189       TaskManagerService taskManagerService,
190       FeatureSourceRepository featureSourceRepository,
191       ApplicationRepository applicationRepository,
192       ConfigurationRepository configurationRepository,
193       FeatureSourceFactoryHelper featureSourceFactoryHelper,
194       SearchIndexRepository searchIndexRepository,
195       UploadRepository uploadRepository,
196       PageRepository pageRepository,
197       JdbcClient jdbcClient) {
198     this.appContext = appContext;
199     this.userRepository = userRepository;
200     this.groupRepository = groupRepository;
201     this.catalogRepository = catalogRepository;
202     this.geoServiceRepository = geoServiceRepository;
203     this.geoServiceHelper = geoServiceHelper;
204     this.solrService = solrService;
205     this.taskManagerService = taskManagerService;
206     this.featureSourceRepository = featureSourceRepository;
207     this.applicationRepository = applicationRepository;
208     this.configurationRepository = configurationRepository;
209     this.featureSourceFactoryHelper = featureSourceFactoryHelper;
210     this.searchIndexRepository = searchIndexRepository;
211     this.uploadRepository = uploadRepository;
212     this.pageRepository = pageRepository;
213     this.jdbcClient = jdbcClient;
214   }
215 
216   @EventListener(ApplicationReadyEvent.class)
217   @Transactional
218   public void populate() throws Exception {
219     InternalAdminAuthentication.setInSecurityContext();
220     try {
221       // Used in conjunction with tailormap-api.database.clean=true so the database has been cleaned
222       // and the latest schema re-created
223       createTestUsersAndGroups();
224       createConfigurationTestData();
225       if (categories.contains("catalog")) {
226         createCatalogTestData();
227       }
228       if (categories.contains("apps")) {
229         createAppTestData();
230       }
231       if (categories.contains("search-index")) {
232         try {
233           createSolrIndex();
234         } catch (Exception e) {
235           logger.error("Exception creating Solr Index for testdata (continuing)", e);
236         }
237       }
238       if (categories.contains("search-index")) {
239         createSearchIndexTasks();
240       }
241       if (categories.contains("pages")) {
242         createPages();
243       }
244       logger.info("Test entities created");
245       if (categories.contains("drawing")) {
246         insertTestDrawing();
247         logger.info("Test drawing created");
248         try {
249           insertDrawingStyle();
250           logger.info("Test drawing style created");
251         } catch (Exception e) {
252           logger.error("Exception creating drawing style", e);
253         }
254       }
255     } finally {
256       InternalAdminAuthentication.clearSecurityContextAuthentication();
257     }
258     if (exit) {
259       // Exit after transaction is completed - for 'mvn verify' to populate testdata before
260       // integration tests
261       new Thread(() -> {
262             try {
263               logger.info("Exiting in 10 seconds");
264               Thread.sleep(10000);
265             } catch (InterruptedException ignored) {
266               // Ignore
267             }
268             SpringApplication.exit(appContext, () -> 0);
269             System.exit(0);
270           })
271           .start();
272     }
273   }
274 
275   public void createTestUsersAndGroups() throws NoSuchElementException {
276     Group groupFoo = new Group().setName("test-foo").setDescription("Used for integration tests.");
277     groupRepository.save(groupFoo);
278 
279     Group groupBar = new Group().setName("test-bar").setDescription("Used for integration tests.");
280     groupBar.addOrUpdateAdminProperty("group-property", true, true);
281     groupBar.addOrUpdateAdminProperty("group-private-property", 999.9, false);
282     groupRepository.save(groupBar);
283 
284     Group groupBaz = new Group().setName("test-baz").setDescription("Used for integration tests.");
285     groupRepository.save(groupBaz);
286 
287     // Normal user
288     User u = new User().setUsername("user").setPassword("{noop}user").setEmail("user@example.com");
289     u.getGroups().addAll(List.of(groupFoo, groupBar, groupBaz));
290     userRepository.save(u);
291 
292     // Foo only user
293     u = new User()
294         .setUsername("foo")
295         .setPassword("{noop}foo")
296         .setEmail("foo@example.com")
297         .setName("Foo only user");
298     u.getGroups().add(groupFoo);
299     userRepository.save(u);
300 
301     u = new User()
302         .setUsername("disabled")
303         .setPassword("{noop}disabled")
304         .setEmail("disabled@example.com")
305         .setEnabled(false);
306     userRepository.save(u);
307 
308     u = new User()
309         .setUsername("expired")
310         .setPassword("{noop}expired")
311         .setEmail("expired@example.com")
312         .setValidUntil(
313             OffsetDateTime.now(ZoneId.systemDefault()).minusDays(1).toZonedDateTime());
314     userRepository.save(u);
315 
316     // Superuser with all access
317     u = new User().setUsername("tm-admin").setPassword(adminHashedPassword);
318     u.addOrUpdateAdminProperty("some-property", "some-value", true);
319     u.addOrUpdateAdminProperty("admin-property", "private-value", false);
320     u.getGroups().add(groupRepository.findById(Group.ADMIN).orElseThrow());
321     u.getGroups().add(groupBar);
322     userRepository.save(u);
323 
324     u = new User().setUsername(Group.ACTUATOR).setPassword(managementHashedPassword);
325     u.getGroups().add(groupRepository.findById(Group.ACTUATOR).orElseThrow());
326     userRepository.save(u);
327   }
328 
329   private final List<AuthorizationRule> ruleAnonymousRead = List.of(new AuthorizationRule()
330       .groupName(Group.ANONYMOUS)
331       .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)));
332 
333   private final List<AuthorizationRule> ruleLoggedIn = List.of(new AuthorizationRule()
334       .groupName(Group.AUTHENTICATED)
335       .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)));
336 
337   @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
338   private void createCatalogTestData() throws Exception {
339     Catalog catalog = catalogRepository.findById(Catalog.MAIN).orElseThrow();
340     CatalogNode rootCatalogNode = catalog.getNodes().getFirst();
341     CatalogNode catalogNode = new CatalogNode().id("test").title("Test services");
342     rootCatalogNode.addChildrenItem(catalogNode.getId());
343     catalog.getNodes().add(catalogNode);
344 
345     String osmAttribution = "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors";
346 
347     Bounds rdTileGridExtent =
348         new Bounds().minx(-285401.92).maxx(595401.92).miny(22598.08).maxy(903401.92);
349 
350     Upload legend = new Upload()
351         .setCategory(Upload.CATEGORY_LEGEND)
352         .setFilename("gemeentegebied-legend.png")
353         .setMimeType("image/png")
354         .setContent(new ClassPathResource("test/gemeentegebied-legend.png").getContentAsByteArray())
355         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
356     uploadRepository.save(legend);
357 
358     Collection<GeoService> services = List.of(
359         new GeoService()
360             .setId("demo")
361             .setProtocol(WMS)
362             .setTitle("Demo")
363             .setPublished(true)
364             .setAuthorizationRules(ruleAnonymousRead)
365             .setUrl("https://demo.tailormap.com/geoserver/geodata/ows?SERVICE=WMS"),
366         new GeoService()
367             .setId("osm")
368             .setProtocol(XYZ)
369             .setTitle("OSM")
370             .setUrl("https://tile.openstreetmap.org/{z}/{x}/{y}.png")
371             .setAuthorizationRules(ruleAnonymousRead)
372             .setSettings(new GeoServiceSettings()
373                 .xyzCrs("EPSG:3857")
374                 .layerSettings(Map.of(
375                     "xyz",
376                     new GeoServiceLayerSettings()
377                         .attribution(osmAttribution)
378                         .maxZoom(19)))),
379         // Layer settings configured later, using the same settings for this one and proxied one
380         new GeoService()
381             .setId("snapshot-geoserver")
382             .setProtocol(WMS)
383             .setTitle("Test GeoServer")
384             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
385             .setAuthorizationRules(ruleAnonymousRead)
386             .setPublished(true),
387         new GeoService()
388             .setId("filtered-snapshot-geoserver")
389             .setProtocol(WMS)
390             .setTitle("Test GeoServer (with authorization rules)")
391             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
392             .setAuthorizationRules(List.of(
393                 new AuthorizationRule()
394                     .groupName("test-foo")
395                     .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
396                 new AuthorizationRule()
397                     .groupName("test-baz")
398                     .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
399             .setSettings(new GeoServiceSettings()
400                 .layerSettings(Map.of(
401                     "BGT",
402                     new GeoServiceLayerSettings()
403                         .addAuthorizationRulesItem(new AuthorizationRule()
404                             .groupName("test-foo")
405                             .decisions(Map.of(
406                                 ACCESS_TYPE_VIEW, AuthorizationRuleDecision.DENY)))
407                         .addAuthorizationRulesItem(new AuthorizationRule()
408                             .groupName("test-baz")
409                             .decisions(Map.of(
410                                 ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))))
411             .setPublished(true),
412         new GeoService()
413             .setId("snapshot-geoserver-proxied")
414             .setProtocol(WMS)
415             .setTitle("Test GeoServer (proxied)")
416             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
417             .setAuthorizationRules(ruleAnonymousRead)
418             .setSettings(new GeoServiceSettings().useProxy(true)),
419         new GeoService()
420             .setId("openbasiskaart")
421             .setProtocol(WMTS)
422             .setTitle("Openbasiskaart")
423             .setUrl("https://www.openbasiskaart.nl/mapcache/wmts")
424             .setAuthorizationRules(ruleAnonymousRead)
425             .setSettings(new GeoServiceSettings()
426                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
427                 .layerSettings(Map.of(
428                     "osm",
429                     new GeoServiceLayerSettings()
430                         .title("Openbasiskaart")
431                         .hiDpiDisabled(false)
432                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
433                         .hiDpiSubstituteLayer("osm-hq")))),
434         new GeoService()
435             .setId("openbasiskaart-proxied")
436             .setProtocol(WMTS)
437             .setTitle("Openbasiskaart (proxied)")
438             .setUrl("https://www.openbasiskaart.nl/mapcache/wmts")
439             .setAuthorizationRules(ruleAnonymousRead)
440             // The service actually doesn't require authentication, but also doesn't mind it
441             // Just for testing
442             .setAuthentication(new ServiceAuthentication()
443                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
444                 .username("test")
445                 .password("test"))
446             .setSettings(new GeoServiceSettings()
447                 .useProxy(true)
448                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
449                 .layerSettings(Map.of(
450                     "osm",
451                     new GeoServiceLayerSettings()
452                         .hiDpiDisabled(false)
453                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
454                         .hiDpiSubstituteLayer("osm-hq")))),
455         new GeoService()
456             .setId("openbasiskaart-tms")
457             .setProtocol(XYZ)
458             .setTitle("Openbasiskaart (TMS)")
459             .setUrl("https://openbasiskaart.nl/mapcache/tms/1.0.0/osm@rd/{z}/{x}/{-y}.png")
460             .setAuthorizationRules(ruleAnonymousRead)
461             .setSettings(
462                 new GeoServiceSettings()
463                     .xyzCrs("EPSG:28992")
464                     .defaultLayerSettings(
465                         new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
466                     .layerSettings(
467                         Map.of(
468                             "xyz",
469                             new GeoServiceLayerSettings()
470                                 .maxZoom(15)
471                                 .tileGridExtent(rdTileGridExtent)
472                                 .hiDpiDisabled(false)
473                                 .hiDpiMode(
474                                     TileLayerHiDpiMode
475                                         .SUBSTITUTELAYERTILEPIXELRATIOONLY)
476                                 .hiDpiSubstituteLayer(
477                                     "https://openbasiskaart.nl/mapcache/tms/1.0.0/osm-hq@rd-hq/{z}/{x}/{-y}.png")))),
478         new GeoService()
479             .setId("pdok-hwh-luchtfotorgb")
480             .setProtocol(WMTS)
481             .setTitle("PDOK HWH luchtfoto")
482             .setUrl("https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0")
483             .setAuthorizationRules(ruleAnonymousRead)
484             .setPublished(true)
485             .setSettings(new GeoServiceSettings()
486                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
487                     .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl)")
488                     .hiDpiDisabled(false))
489                 .putLayerSettingsItem(
490                     "Actueel_orthoHR", new GeoServiceLayerSettings().title("Luchtfoto"))),
491         new GeoService()
492             .setId("b3p-mapproxy-luchtfoto")
493             .setProtocol(XYZ)
494             .setTitle("Luchtfoto (TMS)")
495             .setUrl("https://mapproxy.b3p.nl/tms/1.0.0/luchtfoto/EPSG28992/{z}/{x}/{-y}.jpeg")
496             .setAuthorizationRules(ruleAnonymousRead)
497             .setPublished(true)
498             .setSettings(new GeoServiceSettings()
499                 .xyzCrs("EPSG:28992")
500                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
501                     .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl)")
502                     .hiDpiDisabled(false))
503                 .layerSettings(Map.of(
504                     "xyz",
505                     new GeoServiceLayerSettings()
506                         .maxZoom(14)
507                         .tileGridExtent(rdTileGridExtent)
508                         .hiDpiMode(TileLayerHiDpiMode.SHOWNEXTZOOMLEVEL)))),
509         new GeoService()
510             .setId("at-basemap")
511             .setProtocol(WMTS)
512             .setTitle("basemap.at")
513             .setUrl("https://mapsneu.wien.gv.at/basemapneu/1.0.0/WMTSCapabilities.xml")
514             .setAuthorizationRules(ruleAnonymousRead)
515             .setPublished(true)
516             .setSettings(new GeoServiceSettings()
517                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
518                     .attribution("© [basemap.at](https://basemap.at)")
519                     .hiDpiDisabled(true))
520                 .layerSettings(Map.of(
521                     "geolandbasemap",
522                     new GeoServiceLayerSettings()
523                         .title("Basemap")
524                         .hiDpiDisabled(false)
525                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERTILEPIXELRATIOONLY)
526                         .hiDpiSubstituteLayer("bmaphidpi"),
527                     "bmaporthofoto30cm",
528                     new GeoServiceLayerSettings()
529                         .title("Orthophoto")
530                         .hiDpiDisabled(false)))),
531         new GeoService()
532             .setId("pdok-kadaster-bestuurlijkegebieden")
533             .setProtocol(WMS)
534             .setUrl("https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wms/v1_0?service=WMS")
535             .setAuthorizationRules(ruleAnonymousRead)
536             .setSettings(new GeoServiceSettings()
537                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
538                     .description("This layer shows an administrative boundary."))
539                 // No attribution required: service is CC0
540                 .serverType(GeoServiceSettings.ServerTypeEnum.MAPSERVER)
541                 .useProxy(true)
542                 .putLayerSettingsItem(
543                     "Gemeentegebied",
544                     new GeoServiceLayerSettings()
545                         .legendImageId(legend.getId().toString())))
546             .setPublished(true)
547             .setTitle("PDOK Kadaster bestuurlijke gebieden"),
548         new GeoService()
549             .setId("bestuurlijkegebieden-proxied")
550             .setProtocol(WMS)
551             .setUrl("https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wms/v1_0?service=WMS")
552             .setAuthorizationRules(ruleAnonymousRead)
553             // The service actually doesn't require authentication, but also doesn't mind it
554             // Just for testing that proxied services with auth are not available in public
555             // apps (even when logged in), in any controllers (map, proxy, features)
556             .setAuthentication(new ServiceAuthentication()
557                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
558                 .username("test")
559                 .password("test"))
560             .setSettings(new GeoServiceSettings()
561                 // No attribution required: service is CC0
562                 .serverType(GeoServiceSettings.ServerTypeEnum.MAPSERVER)
563                 .useProxy(true))
564             .setPublished(true)
565             .setTitle("Bestuurlijke gebieden (proxied met auth)"),
566         new GeoService()
567             .setId("3dbag_utrecht")
568             .setProtocol(TILES3D)
569             .setUrl("https://3dtilesnederland.nl/tiles/1.0/implicit/nederland/344.json")
570             .setTitle("3D BAG Utrecht")
571             .setPublished(true)
572             .setAuthorizationRules(ruleAnonymousRead),
573         new GeoService()
574             .setId("ahn_terrain_model")
575             .setProtocol(QUANTIZEDMESH)
576             .setUrl(
577                 "https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1/collections/digitaalterreinmodel")
578             .setTitle("AHN Terrain Model")
579             .setPublished(true)
580             .setAuthorizationRules(ruleAnonymousRead),
581         new GeoService()
582             .setId("3d_basisvoorziening_gebouwen_proxy")
583             .setProtocol(TILES3D)
584             .setUrl(
585                 "https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1_0/collections/gebouwen/3dtiles")
586             .setTitle("3D Basisvoorziening Gebouwen Proxy")
587             .setPublished(true)
588             .setAuthorizationRules(ruleAnonymousRead)
589             .setSettings(new GeoServiceSettings().useProxy(true)),
590         new GeoService()
591             .setId("3d_utrecht_proxied_auth")
592             .setProtocol(TILES3D)
593             .setUrl("https://snapshot.tailormap.nl/tiles3d-proxy/3dtiles")
594             .setTitle("3D Utrecht Proxied with Authorization")
595             .setPublished(true)
596             .setAuthorizationRules(ruleAnonymousRead)
597             .setAuthentication(new ServiceAuthentication()
598                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
599                 .username(tiles3dProxyUsername)
600                 .password(tiles3dProxyPassword))
601             .setSettings(new GeoServiceSettings().useProxy(true))
602         // TODO MapServer WMS "https://wms.geonorge.no/skwms1/wms.adm_enheter_historisk"
603         );
604 
605     if (map5url != null) {
606       GeoServiceLayerSettings osmAttr = new GeoServiceLayerSettings().attribution(osmAttribution);
607       GeoServiceLayerSettings map5Attr = new GeoServiceLayerSettings()
608           .attribution("Kaarten: [Map5.nl](https://map5.nl), data: " + osmAttribution);
609       services = new ArrayList<>(services);
610       services.add(new GeoService()
611           .setId("map5")
612           .setProtocol(WMTS)
613           .setTitle("Map5")
614           .setUrl(map5url)
615           .setAuthorizationRules(ruleAnonymousRead)
616           .setSettings(new GeoServiceSettings()
617               .defaultLayerSettings(new GeoServiceDefaultLayerSettings().hiDpiDisabled(true))
618               .layerSettings(Map.of(
619                   "openlufo",
620                   new GeoServiceLayerSettings()
621                       .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl), "
622                           + osmAttribution),
623                   "luforoadslabels",
624                   osmAttr,
625                   "map5topo",
626                   new GeoServiceLayerSettings()
627                       .attribution(map5Attr.getAttribution())
628                       .hiDpiDisabled(false)
629                       .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
630                       .hiDpiSubstituteLayer("map5topo_hq"),
631                   "map5topo_gray",
632                   map5Attr,
633                   "map5topo_simple",
634                   map5Attr,
635                   "map5topo_simple_gray",
636                   map5Attr,
637                   "opensimpletopo",
638                   osmAttr,
639                   "opensimpletopo_gray",
640                   osmAttr,
641                   "opentopo",
642                   osmAttr,
643                   "opentopo_gray",
644                   osmAttr))));
645     }
646 
647     for (GeoService geoService : services) {
648       try {
649         geoServiceHelper.loadServiceCapabilities(geoService);
650       } catch (Exception e) {
651         logger.error(
652             "Error loading capabilities for {} service URL {}: {}, {}",
653             geoService.getProtocol().getValue(),
654             geoService.getUrl(),
655             e.getClass(),
656             e.getMessage());
657       }
658 
659       geoServiceRepository.save(geoService);
660       catalogNode.addItemsItem(new TailormapObjectRef()
661           .kind(TailormapObjectRef.KindEnum.GEO_SERVICE)
662           .id(geoService.getId()));
663     }
664 
665     CatalogNode wfsFeatureSourceCatalogNode =
666         new CatalogNode().id("wfs_feature_sources").title("WFS feature sources");
667     rootCatalogNode.addChildrenItem(wfsFeatureSourceCatalogNode.getId());
668     catalog.getNodes().add(wfsFeatureSourceCatalogNode);
669 
670     services.stream().filter(s -> s.getProtocol() == WMS).forEach(s -> {
671       geoServiceHelper.findAndSaveRelatedWFS(s);
672       List<TMFeatureSource> linkedSources = featureSourceRepository.findByLinkedServiceId(s.getId());
673       for (TMFeatureSource linkedSource : linkedSources) {
674         wfsFeatureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
675             .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
676             .id(linkedSource.getId().toString()));
677       }
678     });
679 
680     String geodataPassword = "980f1c8A-25933b2";
681 
682     Map<String, TMFeatureSource> featureSources = Map.of(
683         "postgis",
684         new TMFeatureSource()
685             .setProtocol(TMFeatureSource.Protocol.JDBC)
686             .setTitle("PostGIS")
687             .setJdbcConnection(new JDBCConnectionProperties()
688                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
689                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
690                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
691                 .database("geodata")
692                 .schema("public")
693                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
694             .setAuthentication(new ServiceAuthentication()
695                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
696                 .username("geodata")
697                 .password(geodataPassword)),
698         "postgis_osm",
699         new TMFeatureSource()
700             .setProtocol(TMFeatureSource.Protocol.JDBC)
701             .setTitle("PostGIS OSM")
702             .setJdbcConnection(new JDBCConnectionProperties()
703                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
704                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
705                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
706                 .database("geodata")
707                 .schema("osm")
708                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
709             .setAuthentication(new ServiceAuthentication()
710                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
711                 .username("geodata")
712                 .password(geodataPassword)),
713         "oracle",
714         new TMFeatureSource()
715             .setProtocol(TMFeatureSource.Protocol.JDBC)
716             .setTitle("Oracle")
717             .setJdbcConnection(new JDBCConnectionProperties()
718                 .dbtype(JDBCConnectionProperties.DbtypeEnum.ORACLE)
719                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "oracle")
720                 .database("/FREEPDB1")
721                 .schema("GEODATA")
722                 .additionalProperties(Map.of("connectionOptions", "?oracle.jdbc.J2EE13Compliant=true")))
723             .setAuthentication(new ServiceAuthentication()
724                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
725                 .username("geodata")
726                 .password(geodataPassword)),
727         "sqlserver",
728         new TMFeatureSource()
729             .setProtocol(TMFeatureSource.Protocol.JDBC)
730             .setTitle("MS SQL Server")
731             .setJdbcConnection(new JDBCConnectionProperties()
732                 .dbtype(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)
733                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "sqlserver")
734                 .database("geodata")
735                 .schema("dbo")
736                 .additionalProperties(Map.of("connectionOptions", ";encrypt=false")))
737             .setAuthentication(new ServiceAuthentication()
738                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
739                 .username("geodata")
740                 .password(geodataPassword)),
741         "pdok-kadaster-bestuurlijkegebieden",
742         new TMFeatureSource()
743             .setProtocol(TMFeatureSource.Protocol.WFS)
744             .setUrl(
745                 "https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wfs/v1_0?service=WFS&VERSION=2.0.0")
746             .setTitle("Bestuurlijke gebieden")
747             .setNotes(
748                 "Overzicht van de bestuurlijke indeling van Nederland in gemeenten en provincies alsmede de rijksgrens. Gegevens zijn afgeleid uit de Basisregistratie Kadaster (BRK)."));
749     featureSourceRepository.saveAll(featureSources.values());
750 
751     new WFSFeatureSourceHelper().loadCapabilities(featureSources.get("pdok-kadaster-bestuurlijkegebieden"));
752     geoServiceRepository.findById("pdok-kadaster-bestuurlijkegebieden").ifPresent(geoService -> {
753       geoService
754           .getSettings()
755           .getLayerSettings()
756           .put(
757               "Provinciegebied",
758               new GeoServiceLayerSettings()
759                   .description("The administrative boundary of Dutch Provinces, connected to a WFS.")
760                   .featureType(new FeatureTypeRef()
761                       .featureSourceId(featureSources
762                           .get("pdok-kadaster-bestuurlijkegebieden")
763                           .getId())
764                       .featureTypeName(PROVINCIE_FEATURE_TYPE_NAME))
765                   .title("Provinciegebied (WFS)"));
766       geoServiceRepository.save(geoService);
767     });
768 
769     geoServiceRepository.findById("bestuurlijkegebieden-proxied").ifPresent(geoService -> {
770       geoService
771           .getSettings()
772           .getLayerSettings()
773           .put(
774               "Provinciegebied",
775               new GeoServiceLayerSettings()
776                   .featureType(new FeatureTypeRef()
777                       .featureSourceId(featureSources
778                           .get("pdok-kadaster-bestuurlijkegebieden")
779                           .getId())
780                       .featureTypeName(PROVINCIE_FEATURE_TYPE_NAME))
781                   .title("Provinciegebied (WFS, proxied met auth)"));
782       geoServiceRepository.save(geoService);
783     });
784 
785     CatalogNode featureSourceCatalogNode =
786         new CatalogNode().id("feature_sources").title("Test feature sources");
787     rootCatalogNode.addChildrenItem(featureSourceCatalogNode.getId());
788     catalog.getNodes().add(featureSourceCatalogNode);
789 
790     for (TMFeatureSource featureSource : featureSources.values()) {
791       featureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
792           .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
793           .id(featureSource.getId().toString()));
794     }
795     catalogRepository.save(catalog);
796 
797     if (connectToSpatialDbs) {
798       featureSources.values().forEach(fs -> {
799         try {
800           if (fs.getProtocol() == TMFeatureSource.Protocol.JDBC) {
801             new JDBCFeatureSourceHelper().loadCapabilities(fs);
802           } else if (fs.getProtocol() == TMFeatureSource.Protocol.WFS) {
803             new WFSFeatureSourceHelper().loadCapabilities(fs);
804           }
805         } catch (Exception e) {
806           logger.error("Error loading capabilities for feature source {}", fs.getTitle(), e);
807         }
808       });
809 
810       services.stream()
811           // Set layer settings for both the proxied and non-proxied one, but don't overwrite the
812           // authorization rules for the "filtered-snapshot-geoserver" service
813           .filter(s -> s.getId().startsWith("snapshot-geoserver"))
814           .forEach(s -> s.getSettings()
815               .layerSettings(Map.of(
816                   "postgis:begroeidterreindeel",
817                   new GeoServiceLayerSettings()
818                       .description("""
819 This layer shows data from https://www.postgis.net/
820 
821 https://postgis.net/brand.svg""")
822                       .featureType(new FeatureTypeRef()
823                           .featureSourceId(featureSources
824                               .get("postgis")
825                               .getId())
826                           .featureTypeName("begroeidterreindeel")),
827                   "postgis:bak",
828                   new GeoServiceLayerSettings()
829                       .featureType(new FeatureTypeRef()
830                           .featureSourceId(featureSources
831                               .get("postgis")
832                               .getId())
833                           .featureTypeName("bak")),
834                   "postgis:kadastraal_perceel",
835                   new GeoServiceLayerSettings()
836                       .description("cadastral parcel label points")
837                       .featureType(new FeatureTypeRef()
838                           .featureSourceId(featureSources
839                               .get("postgis")
840                               .getId())
841                           .featureTypeName("kadastraal_perceel")),
842                   "sqlserver:wegdeel",
843                   new GeoServiceLayerSettings()
844                       .attribution(
845                           "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)")
846                       .description("""
847 This layer shows data from [MS SQL Server](https://learn.microsoft.com/en-us/sql/relational-databases/spatial/spatial-data-sql-server).
848 
849 https://social.technet.microsoft.com/wiki/cfs-filesystemfile.ashx/__key/communityserver-components-imagefileviewer/communityserver-wikis-components-files-00-00-00-00-05/1884.SQL_5F00_h_5F00_rgb.png_2D00_550x0.png""")
850                       .featureType(new FeatureTypeRef()
851                           .featureSourceId(featureSources
852                               .get("sqlserver")
853                               .getId())
854                           .featureTypeName("wegdeel")),
855                   "oracle:WATERDEEL",
856                   new GeoServiceLayerSettings()
857                       .description("This layer shows data from Oracle Spatial.")
858                       .featureType(new FeatureTypeRef()
859                           .featureSourceId(featureSources
860                               .get("oracle")
861                               .getId())
862                           .featureTypeName("WATERDEEL")),
863                   "postgis:osm_polygon",
864                   new GeoServiceLayerSettings()
865                       .description("This layer shows OSM data from postgis.")
866                       .featureType(new FeatureTypeRef()
867                           .featureSourceId(featureSources
868                               .get("postgis_osm")
869                               .getId())
870                           .featureTypeName("osm_polygon")))));
871     }
872 
873     featureSources.get("pdok-kadaster-bestuurlijkegebieden").getFeatureTypes().stream()
874         .filter(ft -> ft.getName().equals(PROVINCIE_FEATURE_TYPE_NAME))
875         .findFirst()
876         .ifPresent(ft -> {
877           ft.getSettings().addHideAttributesItem("identificatie");
878           ft.getSettings().addHideAttributesItem("ligtInLandCode");
879           ft.getSettings().addHideAttributesItem("fuuid");
880           ft.getSettings().putAttributeSettingsItem("naam", new AttributeSettings().title("Naam"));
881           ft.getSettings()
882               .setTemplate(new FeatureTypeTemplate()
883                   .templateLanguage("simple")
884                   .markupLanguage("markdown")
885                   .template("""
886 ### Provincie
887 Deze provincie heet **{{naam}}** en ligt in _{{ligtInLandNaam}}_.
888 
889 | Attribuut | Waarde             |
890 | --------- | ------------------ |
891 | `code`    | {{code}}           |
892 | `naam`    | {{naam}}           |
893 | `ligt in` | {{ligtInLandNaam}} |"""));
894         });
895 
896     featureSources.get("postgis").getFeatureTypes().stream()
897         .filter(ft -> ft.getName().equals("begroeidterreindeel"))
898         .findFirst()
899         .ifPresent(ft -> {
900           ft.getSettings().addHideAttributesItem("terminationdate");
901           ft.getSettings().addHideAttributesItem("geom_kruinlijn");
902           ft.getSettings().putAttributeSettingsItem("gmlid", new AttributeSettings().title("GML ID"));
903           ft.getSettings()
904               .putAttributeSettingsItem("identificatie", new AttributeSettings().title("Identificatie"));
905           ft.getSettings()
906               .putAttributeSettingsItem(
907                   "tijdstipregistratie", new AttributeSettings().title("Registratie"));
908           ft.getSettings()
909               .putAttributeSettingsItem(
910                   "eindregistratie", new AttributeSettings().title("Eind registratie"));
911           ft.getSettings().putAttributeSettingsItem("class", new AttributeSettings().title("Klasse"));
912           ft.getSettings()
913               .putAttributeSettingsItem("bronhouder", new AttributeSettings().title("Bronhouder"));
914           ft.getSettings()
915               .putAttributeSettingsItem("inonderzoek", new AttributeSettings().title("In onderzoek"));
916           ft.getSettings()
917               .putAttributeSettingsItem(
918                   "relatievehoogteligging", new AttributeSettings().title("Relatieve hoogteligging"));
919           ft.getSettings()
920               .putAttributeSettingsItem("bgt_status", new AttributeSettings().title("BGT status"));
921           ft.getSettings()
922               .putAttributeSettingsItem("plus_status", new AttributeSettings().title("Plus-status"));
923           ft.getSettings()
924               .putAttributeSettingsItem(
925                   "plus_fysiekvoorkomen", new AttributeSettings().title("Plus-fysiek voorkomen"));
926           ft.getSettings()
927               .putAttributeSettingsItem(
928                   "begroeidterreindeeloptalud", new AttributeSettings().title("Op talud"));
929           ft.getSettings().addAttributeOrderItem("identificatie");
930           ft.getSettings().addAttributeOrderItem("bronhouder");
931           ft.getSettings().addAttributeOrderItem("class");
932           ft.getSettings()
933               .addAttachmentAttributesItem(new AttachmentAttributeType()
934                   .attributeName("bijlage")
935                   .maxAttachmentSize(4_000_000L)
936                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
937           try {
938             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
939           } catch (IOException | SQLException e) {
940             throw new RuntimeException("Failed to create attachments table", e);
941           }
942           ft.getSettings().addEditableAttributesItem("identificatie");
943           ft.getSettings().addEditableAttributesItem("bronhouder");
944           ft.getSettings().addEditableAttributesItem("class");
945           ft.getSettings().addEditableAttributesItem("gmlid");
946           ft.getSettings().addEditableAttributesItem("lv_publicatiedatum");
947           ft.getSettings().addEditableAttributesItem("creationdate");
948           ft.getSettings().addEditableAttributesItem("tijdstipregistratie");
949           ft.getSettings().addEditableAttributesItem("eindregistratie");
950           ft.getSettings().addEditableAttributesItem("terminationdate");
951           ft.getSettings().addEditableAttributesItem("inonderzoek");
952           ft.getSettings().addEditableAttributesItem("relatievehoogteligging");
953           ft.getSettings().addEditableAttributesItem("bgt_status");
954           ft.getSettings().addEditableAttributesItem("plus_status");
955           ft.getSettings().addEditableAttributesItem("plus_fysiekvoorkomen");
956           ft.getSettings().addEditableAttributesItem("begroeidterreindeeloptalud");
957         });
958 
959     featureSources.get("postgis").getFeatureTypes().stream()
960         .filter(ft -> ft.getName().equals("bak"))
961         .findFirst()
962         .ifPresent(ft -> {
963           ft.getSettings().addHideAttributesItem("gmlid");
964           ft.getSettings().addHideAttributesItem("lv_publicatiedatum");
965           ft.getSettings().addHideAttributesItem("creationdate");
966           ft.getSettings().addHideAttributesItem("tijdstipregistratie");
967           ft.getSettings().addHideAttributesItem("eindregistratie");
968           ft.getSettings().addHideAttributesItem("terminationdate");
969           ft.getSettings().addHideAttributesItem("inonderzoek");
970           ft.getSettings().addHideAttributesItem("relatievehoogteligging");
971           ft.getSettings().addHideAttributesItem("bgt_status");
972           ft.getSettings().addHideAttributesItem("plus_status");
973           ft.getSettings().addHideAttributesItem("function_");
974           ft.getSettings().addHideAttributesItem("plus_type");
975         });
976 
977     featureSources.get("oracle").getFeatureTypes().stream()
978         .filter(ft -> ft.getName().equals("WATERDEEL"))
979         .findFirst()
980         .ifPresent(ft -> {
981           ft.getSettings()
982               .addAttachmentAttributesItem(new AttachmentAttributeType()
983                   .attributeName("bijlage")
984                   .maxAttachmentSize(4_000_000L)
985                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
986           try {
987             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
988           } catch (IOException | SQLException e) {
989             throw new RuntimeException("Failed to create attachments table", e);
990           }
991           ft.getSettings().addEditableAttributesItem("GMLID");
992           ft.getSettings().addEditableAttributesItem("IDENTIFICATIE");
993           ft.getSettings().addEditableAttributesItem("LV_PUBLICATIEDATUM");
994           ft.getSettings().addEditableAttributesItem("CREATIONDATE");
995           ft.getSettings().addEditableAttributesItem("TIJDSTIPREGISTRATIE");
996           ft.getSettings().addEditableAttributesItem("EINDREGISTRATIE");
997           ft.getSettings().addEditableAttributesItem("TERMINATIONDATE");
998           ft.getSettings().addEditableAttributesItem("BRONHOUDER");
999           ft.getSettings().addEditableAttributesItem("INONDERZOEK");
1000           ft.getSettings().addEditableAttributesItem("RELATIEVEHOOGTELIGGING");
1001           ft.getSettings().addEditableAttributesItem("BGT_STATUS");
1002           ft.getSettings().addEditableAttributesItem("PLUS_STATUS");
1003           ft.getSettings().addEditableAttributesItem("CLASS");
1004           ft.getSettings().addEditableAttributesItem("PLUS_TYPE");
1005         });
1006     featureSources.get("sqlserver").getFeatureTypes().stream()
1007         .filter(ft -> ft.getName().equals("wegdeel"))
1008         .findFirst()
1009         .ifPresent(ft -> {
1010           ft.getSettings()
1011               .addAttachmentAttributesItem(new AttachmentAttributeType()
1012                   .attributeName("bijlage")
1013                   .maxAttachmentSize(4_000_000L)
1014                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1015           try {
1016             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1017           } catch (IOException | SQLException e) {
1018             throw new RuntimeException("Failed to create attachments table", e);
1019           }
1020           ft.getSettings().addEditableAttributesItem("gmlid");
1021           ft.getSettings().addEditableAttributesItem("identificatie");
1022           ft.getSettings().addEditableAttributesItem("lv_publicatiedatum");
1023           ft.getSettings().addEditableAttributesItem("creationdate");
1024           ft.getSettings().addEditableAttributesItem("tijdstipregistratie");
1025           ft.getSettings().addEditableAttributesItem("eindregistratie");
1026           ft.getSettings().addEditableAttributesItem("terminationdate");
1027           ft.getSettings().addEditableAttributesItem("bronhouder");
1028           ft.getSettings().addEditableAttributesItem("inonderzoek");
1029           ft.getSettings().addEditableAttributesItem("relatievehoogteligging");
1030           ft.getSettings().addEditableAttributesItem("bgt_status");
1031           ft.getSettings().addEditableAttributesItem("plus_status");
1032           ft.getSettings().addEditableAttributesItem("function_");
1033           ft.getSettings().addEditableAttributesItem("plus_functiewegdeel");
1034           ft.getSettings().addEditableAttributesItem("plus_fysiekvoorkomenwegdeel");
1035           ft.getSettings().addEditableAttributesItem("surfacematerial");
1036           ft.getSettings().addEditableAttributesItem("wegdeeloptalud");
1037         });
1038 
1039     featureSources.get("postgis").getFeatureTypes().stream()
1040         .filter(ft -> ft.getName().equals("kadastraal_perceel"))
1041         .findFirst()
1042         .ifPresent(ft -> {
1043           ft.getSettings()
1044               .addAttachmentAttributesItem(new AttachmentAttributeType()
1045                   .attributeName("bijlage")
1046                   .maxAttachmentSize(4_000_000L)
1047                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1048           try {
1049             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1050           } catch (IOException | SQLException e) {
1051             throw new RuntimeException("Failed to create attachments table for kadastraal_perceel", e);
1052           }
1053           // hide primary key
1054           ft.getSettings().addHideAttributesItem("gml_id");
1055           // make some attributes editable
1056           ft.getSettings().addEditableAttributesItem("begin_geldigheid");
1057           ft.getSettings().addEditableAttributesItem("tijdstip_registratie");
1058         });
1059 
1060     featureSources.get("postgis_osm").getFeatureTypes().stream()
1061         .filter(ft -> ft.getName().equals("osm_polygon"))
1062         .findFirst()
1063         .ifPresent(ft -> {
1064           ft.getSettings().addEditableAttributesItem("osm_id");
1065           ft.getSettings().addEditableAttributesItem("building");
1066           ft.getSettings().addEditableAttributesItem("z_order");
1067         });
1068   }
1069 
1070   public void createAppTestData() throws Exception {
1071     Upload logo = new Upload()
1072         .setCategory(Upload.CATEGORY_APP_LOGO)
1073         .setFilename("gradient.svg")
1074         .setMimeType("image/svg+xml")
1075         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
1076         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1077     uploadRepository.save(logo);
1078 
1079     List<AppTreeNode> baseNodes = List.of(
1080         new AppTreeLayerNode()
1081             .objectType("AppTreeLayerNode")
1082             .id("lyr:openbasiskaart:osm")
1083             .serviceId("openbasiskaart")
1084             .layerName("osm")
1085             .visible(true),
1086         new AppTreeLayerNode()
1087             .objectType("AppTreeLayerNode")
1088             .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1089             .serviceId("pdok-hwh-luchtfotorgb")
1090             .layerName("Actueel_orthoHR")
1091             .visible(false));
1092 
1093     Application app = new Application()
1094         .setName("default")
1095         .setTitle("Tailormap demo")
1096         .setCrs("EPSG:28992")
1097         .setAuthorizationRules(ruleAnonymousRead)
1098         .setComponents(List.of(
1099             new Component()
1100                 .type("SIMPLE_SEARCH")
1101                 .config(new ComponentConfig()
1102                     .enabled(true)
1103                     .putAdditionalProperty("municipalities", List.of("0344"))),
1104             new Component().type("EDIT").config(new ComponentConfig().enabled(true)),
1105             new Component()
1106                 .type("TOC")
1107                 .config(new ComponentConfig()
1108                     .enabled(true)
1109                     .putAdditionalProperty("showEditLayerIcon", true)),
1110             new Component()
1111                 .type("COORDINATE_LINK_WINDOW")
1112                 .config(new ComponentConfig()
1113                     .enabled(true)
1114                     .putAdditionalProperty(
1115                         "urls",
1116                         List.of(
1117                             Map.of(
1118                                 "id",
1119                                 "google-maps",
1120                                 "url",
1121                                 "https://www.google.com/maps/@[lat],[lon],18z",
1122                                 "alias",
1123                                 "Google Maps",
1124                                 "projection",
1125                                 "EPSG:4326"),
1126                             Map.of(
1127                                 "id",
1128                                 "tm-demo",
1129                                 "url",
1130                                 "https://demo.tailormap.com/#@[X],[Y],18",
1131                                 "alias",
1132                                 "Tailormap demo",
1133                                 "projection",
1134                                 "EPSG:28992"))))))
1135         .setContentRoot(new AppContent()
1136             .addBaseLayerNodesItem(new AppTreeLevelNode()
1137                 .objectType("AppTreeLevelNode")
1138                 .id("root-base-layers")
1139                 .root(true)
1140                 .title("Base layers")
1141                 .childrenIds(List.of(
1142                     "lyr:openbasiskaart:osm",
1143                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1144                     "lyr:openbasiskaart-proxied:osm",
1145                     "lyr:openbasiskaart-tms:xyz",
1146                     "lyr:b3p-mapproxy-luchtfoto:xyz")))
1147             .addBaseLayerNodesItem(
1148                 // This layer from a secured proxied service should not be proxyable in a
1149                 // public app, see test_wms_secured_proxy_not_in_public_app() testcase
1150                 new AppTreeLayerNode()
1151                     .objectType("AppTreeLayerNode")
1152                     .id("lyr:openbasiskaart-proxied:osm")
1153                     .serviceId("openbasiskaart-proxied")
1154                     .layerName("osm")
1155                     .visible(false))
1156             .addBaseLayerNodesItem(new AppTreeLayerNode()
1157                 .objectType("AppTreeLayerNode")
1158                 .id("lyr:openbasiskaart-tms:xyz")
1159                 .serviceId("openbasiskaart-tms")
1160                 .layerName("xyz")
1161                 .visible(false))
1162             .addBaseLayerNodesItem(new AppTreeLayerNode()
1163                 .objectType("AppTreeLayerNode")
1164                 .id("lyr:b3p-mapproxy-luchtfoto:xyz")
1165                 .serviceId("b3p-mapproxy-luchtfoto")
1166                 .layerName("xyz")
1167                 .visible(false))
1168             .addLayerNodesItem(new AppTreeLevelNode()
1169                 .objectType("AppTreeLevelNode")
1170                 .id("root")
1171                 .root(true)
1172                 .title("Layers")
1173                 .childrenIds(List.of(
1174                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1175                     "lyr:bestuurlijkegebieden-proxied:Provinciegebied",
1176                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1177                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1178                     "lyr:snapshot-geoserver:postgis:bak",
1179                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1180                     "lyr:snapshot-geoserver:sqlserver:wegdeel",
1181                     "lyr:snapshot-geoserver:oracle:WATERDEEL",
1182                     "lyr:snapshot-geoserver:BGT",
1183                     "lvl:proxied",
1184                     "lvl:osm",
1185                     "lvl:archeo")))
1186             .addLayerNodesItem(new AppTreeLayerNode()
1187                 .objectType("AppTreeLayerNode")
1188                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1189                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1190                 .layerName("Provinciegebied")
1191                 .visible(true))
1192             // This is a layer from proxied service with auth that should also not be
1193             // visible, but it has a feature source attached, should also be denied for
1194             // features access and not be included in TOC
1195             .addLayerNodesItem(new AppTreeLayerNode()
1196                 .objectType("AppTreeLayerNode")
1197                 .id("lyr:bestuurlijkegebieden-proxied:Provinciegebied")
1198                 .serviceId("bestuurlijkegebieden-proxied")
1199                 .layerName("Provinciegebied")
1200                 .visible(false))
1201             .addLayerNodesItem(new AppTreeLayerNode()
1202                 .objectType("AppTreeLayerNode")
1203                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1204                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1205                 .layerName("Gemeentegebied")
1206                 .visible(true))
1207             .addLayerNodesItem(new AppTreeLayerNode()
1208                 .objectType("AppTreeLayerNode")
1209                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1210                 .serviceId("snapshot-geoserver")
1211                 .layerName("postgis:begroeidterreindeel")
1212                 .visible(true))
1213             .addLayerNodesItem(new AppTreeLayerNode()
1214                 .objectType("AppTreeLayerNode")
1215                 .id("lyr:snapshot-geoserver:postgis:bak")
1216                 .serviceId("snapshot-geoserver")
1217                 .layerName("postgis:bak")
1218                 .visible(false))
1219             .addLayerNodesItem(new AppTreeLayerNode()
1220                 .objectType("AppTreeLayerNode")
1221                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1222                 .serviceId("snapshot-geoserver")
1223                 .layerName("postgis:kadastraal_perceel")
1224                 .visible(false))
1225             .addLayerNodesItem(new AppTreeLayerNode()
1226                 .objectType("AppTreeLayerNode")
1227                 .id("lyr:snapshot-geoserver:sqlserver:wegdeel")
1228                 .serviceId("snapshot-geoserver")
1229                 .layerName("sqlserver:wegdeel")
1230                 .visible(true))
1231             .addLayerNodesItem(new AppTreeLayerNode()
1232                 .objectType("AppTreeLayerNode")
1233                 .id("lyr:snapshot-geoserver:oracle:WATERDEEL")
1234                 .serviceId("snapshot-geoserver")
1235                 .layerName("oracle:WATERDEEL")
1236                 .visible(true))
1237             .addLayerNodesItem(new AppTreeLayerNode()
1238                 .objectType("AppTreeLayerNode")
1239                 .id("lyr:snapshot-geoserver:BGT")
1240                 .serviceId("snapshot-geoserver")
1241                 .layerName("BGT")
1242                 .visible(false))
1243             .addLayerNodesItem(new AppTreeLevelNode()
1244                 .objectType("AppTreeLevelNode")
1245                 .id("lvl:proxied")
1246                 .title("Proxied")
1247                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1248             .addLayerNodesItem(new AppTreeLayerNode()
1249                 .objectType("AppTreeLayerNode")
1250                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1251                 .serviceId("snapshot-geoserver-proxied")
1252                 .layerName("postgis:begroeidterreindeel")
1253                 .visible(false))
1254             .addLayerNodesItem(new AppTreeLevelNode()
1255                 .objectType("AppTreeLevelNode")
1256                 .id("lvl:osm")
1257                 .title("OSM")
1258                 .childrenIds(List.of("lyr:snapshot-geoserver:postgis:osm_polygon")))
1259             .addLayerNodesItem(new AppTreeLayerNode()
1260                 .objectType("AppTreeLayerNode")
1261                 .id("lyr:snapshot-geoserver:postgis:osm_polygon")
1262                 .serviceId("snapshot-geoserver")
1263                 .layerName("postgis:osm_polygon")
1264                 .visible(false))
1265             .addLayerNodesItem(new AppTreeLevelNode()
1266                 .objectType("AppTreeLevelNode")
1267                 .id("lvl:archeo")
1268                 .title("Archeology")
1269                 .childrenIds(List.of("lyr:demo:geomorfologie")))
1270             .addLayerNodesItem(new AppTreeLayerNode()
1271                 .objectType("AppTreeLayerNode")
1272                 .id("lyr:demo:geomorfologie")
1273                 .serviceId("demo")
1274                 .layerName("geomorfologie")
1275                 .visible(true)))
1276         .setStyling(new AppStyling().logo(logo.getId().toString()))
1277         .setSettings(new AppSettings()
1278             .putLayerSettingsItem("lyr:openbasiskaart:osm", new AppLayerSettings().title("Openbasiskaart"))
1279             .putLayerSettingsItem(
1280                 "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", new AppLayerSettings().title("Luchtfoto"))
1281             .putLayerSettingsItem(
1282                 "lyr:openbasiskaart-proxied:osm",
1283                 new AppLayerSettings().title("Openbasiskaart (proxied)"))
1284             .putLayerSettingsItem(
1285                 "lyr:snapshot-geoserver:oracle:WATERDEEL",
1286                 new AppLayerSettings()
1287                     .opacity(50)
1288                     .title("Waterdeel overridden title")
1289                     .editable(true)
1290                     .description("This is the layer description from the app layer setting.")
1291                     .attribution(
1292                         "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)"))
1293             .putLayerSettingsItem(
1294                 "lyr:snapshot-geoserver:postgis:osm_polygon",
1295                 new AppLayerSettings()
1296                     .description("OpenStreetMap polygon data in EPSG:3857")
1297                     .opacity(60)
1298                     .editable(true)
1299                     .title("OSM Polygon (EPSG:3857)")
1300                     .attribution(
1301                         "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors"))
1302             .putLayerSettingsItem(
1303                 "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1304                 new AppLayerSettings()
1305                     .editable(true)
1306                     .addHideAttributesItem("begroeidterreindeeloptalud")
1307                     .addReadOnlyAttributesItem("eindregistratie")
1308                     .selectedStyles(
1309                         List.of(
1310                             new WMSStyle()
1311                                 .name("begroeidterreindeel")
1312                                 .title("Visualisatie van de begroeide terreindelen")
1313                                 .abstractText(
1314                                     "Deze stylesheet bevat de regels voor de visualisatie van het objecttype Begroeid Terreindeel")
1315                                 .legendUrl(
1316                                     URI.create(
1317                                         "https://snapshot.tailormap.nl/geoserver/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image%2Fpng&width=20&height=20&layer=test%3Apostgis_begroeidterreindeel")),
1318                             new WMSStyle()
1319                                 .name("purple_polygon")
1320                                 .title("purple_polygon")
1321                                 .abstractText(null)
1322                                 .legendUrl(
1323                                     URI.create(
1324                                         "https://snapshot.tailormap.nl/geoserver/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image%2Fpng&width=20&height=20&layer=test%3Apostgis_begroeidterreindeel&style=purple_polygon")))))
1325             .putLayerSettingsItem(
1326                 "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1327                 new AppLayerSettings().editable(true).addReadOnlyAttributesItem("aanduiding"))
1328             .putLayerSettingsItem(
1329                 "lyr:snapshot-geoserver:sqlserver:wegdeel", new AppLayerSettings().editable(true))
1330             .putLayerSettingsItem(
1331                 "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1332                 new AppLayerSettings().editable(false))
1333             .putLayerSettingsItem(
1334                 "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1335                 new AppLayerSettings()
1336                     .hiddenFunctionality(Set.of(FEATURE_INFO, ATTRIBUTE_LIST, EXPORT)))
1337             .addFilterGroupsItem(new FilterGroup()
1338                 .id("filtergroup1")
1339                 .source("PRESET")
1340                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1341                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1342                 .operator(FilterGroup.OperatorEnum.AND)
1343                 .addFiltersItem(new Filter()
1344                     .id("filter1")
1345                     .type(Filter.TypeEnum.ATTRIBUTE)
1346                     .condition(Filter.ConditionEnum.BEFORE)
1347                     .addValueItem("2025-06-05")
1348                     .attribute("creationdate")
1349                     .attributeType(Filter.AttributeTypeEnum.DATE))
1350                 .addFiltersItem(new Filter()
1351                     .id("filter2")
1352                     .type(Filter.TypeEnum.ATTRIBUTE)
1353                     .condition(Filter.ConditionEnum.UNIQUE_VALUES)
1354                     .addValueItem("bodembedekkers")
1355                     .addValueItem("bosplantsoen")
1356                     .addValueItem("gras- en kruidachtigen")
1357                     .attribute("plus_fysiekvoorkomen")
1358                     .attributeType(Filter.AttributeTypeEnum.STRING)
1359                     .editConfiguration(new FilterEditConfiguration()
1360                         .filterTool(FilterEditConfiguration.FilterToolEnum.CHECKBOX)
1361                         .attributeValuesSettings(List.of(
1362                             new AttributeValueSettings()
1363                                 .value("bodembedekkers")
1364                                 .initiallySelected(true)
1365                                 .selectable(true)
1366                                 .alias("Bodembedekkers"),
1367                             new AttributeValueSettings()
1368                                 .value("bosplantsoen")
1369                                 .initiallySelected(true)
1370                                 .selectable(true)
1371                                 .alias("Bosplantsoen"),
1372                             new AttributeValueSettings()
1373                                 .value("gras- en kruidachtigen")
1374                                 .initiallySelected(true)
1375                                 .selectable(true)
1376                                 .alias("Gras- en kruidachtigen"),
1377                             new AttributeValueSettings()
1378                                 .value("griend en hakhout")
1379                                 .initiallySelected(false)
1380                                 .selectable(true),
1381                             new AttributeValueSettings()
1382                                 .value("heesters")
1383                                 .initiallySelected(false)
1384                                 .selectable(true),
1385                             new AttributeValueSettings()
1386                                 .value("planten")
1387                                 .initiallySelected(false)
1388                                 .selectable(true),
1389                             new AttributeValueSettings()
1390                                 .value("struikrozen")
1391                                 .initiallySelected(false)
1392                                 .selectable(true),
1393                             new AttributeValueSettings()
1394                                 .value("waardeOnbekend")
1395                                 .initiallySelected(false)
1396                                 .selectable(true))))))
1397             .addFilterGroupsItem(new FilterGroup()
1398                 .id("filtergroup2")
1399                 .source("PRESET")
1400                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1401                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1402                 .operator(FilterGroup.OperatorEnum.AND)
1403                 .addFiltersItem(new Filter()
1404                     .id("filter3")
1405                     .type(Filter.TypeEnum.ATTRIBUTE)
1406                     .condition(Filter.ConditionEnum.u)
1407                     .addValueItem("1")
1408                     .addValueItem("12419")
1409                     .attribute("perceelnummer")
1410                     .attributeType(Filter.AttributeTypeEnum.DOUBLE)
1411                     .editConfiguration(new FilterEditConfiguration()
1412                         .filterTool(FilterEditConfiguration.FilterToolEnum.SLIDER)
1413                         .initialLowerValue(1d)
1414                         .initialUpperValue(12419d)
1415                         .minimumValue(1d)
1416                         .maximumValue(12419d)))));
1417 
1418     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1419     app.setInitialExtent(
1420         new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d));
1421     app.setMaxExtent(new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d));
1422 
1423     if (map5url != null) {
1424       AppTreeLevelNode root =
1425           (AppTreeLevelNode) app.getContentRoot().getBaseLayerNodes().getFirst();
1426       List<String> childrenIds = new ArrayList<>(root.getChildrenIds());
1427       childrenIds.add("lyr:map5:map5topo");
1428       childrenIds.add("lyr:map5:map5topo_simple");
1429       childrenIds.add("lvl:luchtfoto-labels");
1430       root.setChildrenIds(childrenIds);
1431       app.getSettings()
1432           .putLayerSettingsItem("lyr:map5:map5topo", new AppLayerSettings().title("Map5"))
1433           .putLayerSettingsItem("lyr:map5:map5topo_simple", new AppLayerSettings().title("Map5 simple"));
1434       app.getContentRoot()
1435           .addBaseLayerNodesItem(new AppTreeLayerNode()
1436               .objectType("AppTreeLayerNode")
1437               .id("lyr:map5:map5topo")
1438               .serviceId("map5")
1439               .layerName("map5topo")
1440               .visible(false))
1441           .addBaseLayerNodesItem(new AppTreeLayerNode()
1442               .objectType("AppTreeLayerNode")
1443               .id("lyr:map5:map5topo_simple")
1444               .serviceId("map5")
1445               .layerName("map5topo_simple")
1446               .visible(false))
1447           .addBaseLayerNodesItem(new AppTreeLevelNode()
1448               .objectType("AppTreeLevelNode")
1449               .id("lvl:luchtfoto-labels")
1450               .title("Luchtfoto met labels")
1451               .addChildrenIdsItem("lyr:map5:luforoadslabels")
1452               .addChildrenIdsItem("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2"))
1453           .addBaseLayerNodesItem(new AppTreeLayerNode()
1454               .objectType("AppTreeLayerNode")
1455               .id("lyr:map5:luforoadslabels")
1456               .serviceId("map5")
1457               .layerName("luforoadslabels")
1458               .visible(false))
1459           .addBaseLayerNodesItem(new AppTreeLayerNode()
1460               .objectType("AppTreeLayerNode")
1461               .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2")
1462               .serviceId("pdok-hwh-luchtfotorgb")
1463               .layerName("Actueel_orthoHR")
1464               .visible(false));
1465     }
1466 
1467     applicationRepository.save(app);
1468 
1469     app = new Application()
1470         .setName("base")
1471         .setTitle("Service base app")
1472         .setCrs("EPSG:28992")
1473         .setAuthorizationRules(ruleAnonymousRead)
1474         .setContentRoot(new AppContent()
1475             .addBaseLayerNodesItem(new AppTreeLevelNode()
1476                 .objectType("AppTreeLevelNode")
1477                 .id("root-base-layers")
1478                 .root(true)
1479                 .title("Base layers")
1480                 .childrenIds(List.of(
1481                     "lyr:openbasiskaart:osm", "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR"))));
1482     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1483     applicationRepository.save(app);
1484 
1485     app = new Application()
1486         .setName("secured")
1487         .setTitle("secured app")
1488         .setCrs("EPSG:28992")
1489         .setAuthorizationRules(ruleLoggedIn)
1490         .setContentRoot(new AppContent()
1491             .addBaseLayerNodesItem(new AppTreeLevelNode()
1492                 .objectType("AppTreeLevelNode")
1493                 .id("root-base-layers")
1494                 .root(true)
1495                 .title("Base layers")
1496                 .childrenIds(List.of(
1497                     "lyr:openbasiskaart:osm",
1498                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1499                     "lyr:openbasiskaart-proxied:osm")))
1500             .addBaseLayerNodesItem(new AppTreeLayerNode()
1501                 .objectType("AppTreeLayerNode")
1502                 .id("lyr:openbasiskaart-proxied:osm")
1503                 .serviceId("openbasiskaart-proxied")
1504                 .layerName("osm")
1505                 .visible(false))
1506             .addLayerNodesItem(new AppTreeLevelNode()
1507                 .objectType("AppTreeLevelNode")
1508                 .id("root")
1509                 .root(true)
1510                 .title("Layers")
1511                 .childrenIds(List.of(
1512                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1513                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1514                     "lvl:proxied")))
1515             .addLayerNodesItem(new AppTreeLayerNode()
1516                 .objectType("AppTreeLayerNode")
1517                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1518                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1519                 .layerName("Gemeentegebied")
1520                 .visible(true))
1521             .addLayerNodesItem(new AppTreeLayerNode()
1522                 .objectType("AppTreeLayerNode")
1523                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1524                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1525                 .layerName("Provinciegebied")
1526                 .visible(false))
1527             .addLayerNodesItem(new AppTreeLevelNode()
1528                 .objectType("AppTreeLevelNode")
1529                 .id("lvl:proxied")
1530                 .title("Proxied")
1531                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1532             .addLayerNodesItem(new AppTreeLayerNode()
1533                 .objectType("AppTreeLayerNode")
1534                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1535                 .serviceId("snapshot-geoserver-proxied")
1536                 .layerName("postgis:begroeidterreindeel")
1537                 .visible(false)))
1538         .setSettings(
1539             new AppSettings()
1540                 .putLayerSettingsItem(
1541                     "lyr:openbasiskaart-proxied:osm",
1542                     new AppLayerSettings().title("Openbasiskaart (proxied)"))
1543                 .putLayerSettingsItem(
1544                     "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1545                     new AppLayerSettings()
1546                         .description("This layer should render using purple polygons")
1547                         .selectedStyles(
1548                             List.of(
1549                                 new WMSStyle()
1550                                     .name("purple_polygon")
1551                                     .title("purple_polygon")
1552                                     .abstractText(null)
1553                                     .legendUrl(
1554                                         URI.create(
1555                                             "https://snapshot.tailormap.nl/geoserver/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image%2Fpng&width=20&height=20&layer=test%3Apostgis_begroeidterreindeel&style=purple_polygon"))))));
1556 
1557     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1558     applicationRepository.save(app);
1559 
1560     app = new Application()
1561         .setName("secured-auth")
1562         .setTitle("secured (with authorizations)")
1563         .setCrs("EPSG:28992")
1564         .setAuthorizationRules(List.of(
1565             new AuthorizationRule()
1566                 .groupName("test-foo")
1567                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
1568             new AuthorizationRule()
1569                 .groupName("test-bar")
1570                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
1571         .setContentRoot(new AppContent()
1572             .addLayerNodesItem(new AppTreeLevelNode()
1573                 .objectType("AppTreeLevelNode")
1574                 .id("root")
1575                 .root(true)
1576                 .title("Layers")
1577                 .childrenIds(List.of("lvl:needs-auth", "lvl:public")))
1578             .addLayerNodesItem(new AppTreeLevelNode()
1579                 .objectType("AppTreeLevelNode")
1580                 .id("lvl:public")
1581                 .title("Public")
1582                 .childrenIds(List.of("lyr:snapshot-geoserver:BGT")))
1583             .addLayerNodesItem(new AppTreeLevelNode()
1584                 .objectType("AppTreeLevelNode")
1585                 .id("lvl:needs-auth")
1586                 .title("Needs auth")
1587                 .childrenIds(List.of(
1588                     "lyr:filtered-snapshot-geoserver:BGT",
1589                     "lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1590             .addLayerNodesItem(new AppTreeLayerNode()
1591                 .objectType("AppTreeLayerNode")
1592                 .id("lyr:filtered-snapshot-geoserver:BGT")
1593                 .serviceId("filtered-snapshot-geoserver")
1594                 .layerName("BGT")
1595                 .visible(true))
1596             .addLayerNodesItem(new AppTreeLayerNode()
1597                 .objectType("AppTreeLayerNode")
1598                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1599                 .serviceId("filtered-snapshot-geoserver")
1600                 .layerName("postgis:begroeidterreindeel")
1601                 .visible(true))
1602             .addLayerNodesItem(new AppTreeLayerNode()
1603                 .objectType("AppTreeLayerNode")
1604                 .id("lyr:snapshot-geoserver:BGT")
1605                 .serviceId("snapshot-geoserver")
1606                 .layerName("BGT")
1607                 .visible(true)));
1608 
1609     applicationRepository.save(app);
1610 
1611     app = new Application()
1612         .setName("austria")
1613         .setCrs("EPSG:3857")
1614         .setAuthorizationRules(ruleAnonymousRead)
1615         .setTitle("Austria")
1616         .setInitialExtent(
1617             new Bounds().minx(987982d).miny(5799551d).maxx(1963423d).maxy(6320708d))
1618         .setMaxExtent(
1619             new Bounds().minx(206516d).miny(5095461d).maxx(3146930d).maxy(7096232d))
1620         .setContentRoot(new AppContent()
1621             .addBaseLayerNodesItem(new AppTreeLevelNode()
1622                 .objectType("AppTreeLevelNode")
1623                 .id("root-base-layers")
1624                 .root(true)
1625                 .title("Base layers")
1626                 .childrenIds(List.of(
1627                     "lyr:at-basemap:geolandbasemap",
1628                     "lyr:at-basemap:orthofoto",
1629                     "lvl:orthofoto-labels",
1630                     "lyr:osm:xyz")))
1631             .addBaseLayerNodesItem(new AppTreeLayerNode()
1632                 .objectType("AppTreeLayerNode")
1633                 .id("lyr:at-basemap:geolandbasemap")
1634                 .serviceId("at-basemap")
1635                 .layerName("geolandbasemap")
1636                 .visible(true))
1637             .addBaseLayerNodesItem(new AppTreeLayerNode()
1638                 .objectType("AppTreeLayerNode")
1639                 .id("lyr:at-basemap:orthofoto")
1640                 .serviceId("at-basemap")
1641                 .layerName("bmaporthofoto30cm")
1642                 .visible(false))
1643             .addBaseLayerNodesItem(new AppTreeLevelNode()
1644                 .objectType("AppTreeLevelNode")
1645                 .id("lvl:orthofoto-labels")
1646                 .title("Orthophoto with labels")
1647                 .childrenIds(List.of("lyr:at-basemap:bmapoverlay", "lyr:at-basemap:orthofoto_2")))
1648             .addBaseLayerNodesItem(new AppTreeLayerNode()
1649                 .objectType("AppTreeLayerNode")
1650                 .id("lyr:at-basemap:bmapoverlay")
1651                 .serviceId("at-basemap")
1652                 .layerName("bmapoverlay")
1653                 .visible(false))
1654             .addBaseLayerNodesItem(new AppTreeLayerNode()
1655                 .objectType("AppTreeLayerNode")
1656                 .id("lyr:at-basemap:orthofoto_2")
1657                 .serviceId("at-basemap")
1658                 .layerName("bmaporthofoto30cm")
1659                 .visible(false))
1660             .addBaseLayerNodesItem(new AppTreeLayerNode()
1661                 .objectType("AppTreeLayerNode")
1662                 .id("lyr:osm:xyz")
1663                 .serviceId("osm")
1664                 .layerName("xyz")
1665                 .visible(false)));
1666 
1667     applicationRepository.save(app);
1668 
1669     app = new Application()
1670         .setName("3d_utrecht")
1671         .setCrs("EPSG:3857")
1672         .setAuthorizationRules(ruleLoggedIn)
1673         .setTitle("3D Utrecht")
1674         .setInitialExtent(
1675             new Bounds().minx(558390d).miny(6818485d).maxx(566751d).maxy(6824036d))
1676         .setMaxExtent(
1677             new Bounds().minx(91467d).miny(6496479d).maxx(1037043d).maxy(7147453d))
1678         .setSettings(new AppSettings().uiSettings(new AppUiSettings().enable3D(true)))
1679         .setContentRoot(new AppContent()
1680             .addBaseLayerNodesItem(new AppTreeLevelNode()
1681                 .objectType("AppTreeLevelNode")
1682                 .id("root-base-layers")
1683                 .root(true)
1684                 .title("Base layers")
1685                 .childrenIds(List.of("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", "lyr:osm:xyz")))
1686             .addBaseLayerNodesItem(new AppTreeLayerNode()
1687                 .objectType("AppTreeLayerNode")
1688                 .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1689                 .serviceId("pdok-hwh-luchtfotorgb")
1690                 .layerName("Actueel_orthoHR")
1691                 .visible(true))
1692             .addBaseLayerNodesItem(new AppTreeLayerNode()
1693                 .objectType("AppTreeLayerNode")
1694                 .id("lyr:osm:xyz")
1695                 .serviceId("osm")
1696                 .layerName("xyz")
1697                 .visible(false))
1698             .addLayerNodesItem(new AppTreeLevelNode()
1699                 .objectType("AppTreeLevelNode")
1700                 .id("root")
1701                 .root(true)
1702                 .title("Layers")
1703                 .childrenIds(List.of(
1704                     "lyr:3dbag_utrecht:tiles3d",
1705                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1706                     "lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d",
1707                     "lyr:3d_utrecht_proxied_auth:tiles3d")))
1708             .addLayerNodesItem(new AppTreeLayerNode()
1709                 .objectType("AppTreeLayerNode")
1710                 .id("lyr:3dbag_utrecht:tiles3d")
1711                 .serviceId("3dbag_utrecht")
1712                 .layerName("tiles3d")
1713                 .visible(true))
1714             .addLayerNodesItem(new AppTreeLayerNode()
1715                 .objectType("AppTreeLayerNode")
1716                 .id("lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d")
1717                 .serviceId("3d_basisvoorziening_gebouwen_proxy")
1718                 .layerName("tiles3d")
1719                 .visible(false))
1720             .addLayerNodesItem(new AppTreeLayerNode()
1721                 .objectType("AppTreeLayerNode")
1722                 .id("lyr:3d_utrecht_proxied_auth:tiles3d")
1723                 .serviceId("3d_utrecht_proxied_auth")
1724                 .layerName("tiles3d")
1725                 .visible(false))
1726             .addLayerNodesItem(new AppTreeLayerNode()
1727                 .objectType("AppTreeLayerNode")
1728                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1729                 .serviceId("snapshot-geoserver")
1730                 .layerName("postgis:begroeidterreindeel")
1731                 .visible(true))
1732             .addTerrainLayerNodesItem(new AppTreeLevelNode()
1733                 .objectType("AppTreeLevelNode")
1734                 .id("root-terrain-layers")
1735                 .root(true)
1736                 .title("Terrain Layers")
1737                 .childrenIds(List.of("lyr:ahn_terrain_model:quantizedmesh")))
1738             .addTerrainLayerNodesItem(new AppTreeLayerNode()
1739                 .objectType("AppTreeLayerNode")
1740                 .id("lyr:ahn_terrain_model:quantizedmesh")
1741                 .serviceId("ahn_terrain_model")
1742                 .layerName("quantizedmesh")
1743                 .visible(false)));
1744 
1745     applicationRepository.save(app);
1746 
1747     app = new Application()
1748         .setName("public-with-auth")
1749         .setTitle("Public app with one restricted layer in a group")
1750         .setCrs("EPSG:28992")
1751         .setAuthorizationRules(ruleAnonymousRead)
1752         .setInitialExtent(
1753             new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d))
1754         .setMaxExtent(
1755             new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d))
1756         .setStyling(new AppStyling().logo(logo.getId().toString()))
1757         .setContentRoot(new AppContent()
1758             .addBaseLayerNodesItem(new AppTreeLevelNode()
1759                 .objectType("AppTreeLevelNode")
1760                 .id("root")
1761                 .root(true)
1762                 .title("Basemaps")
1763                 .childrenIds(List.of("lyr:openbasiskaart:osm")))
1764             .addBaseLayerNodesItem(new AppTreeLayerNode()
1765                 .objectType("AppTreeLayerNode")
1766                 .id("lyr:openbasiskaart:osm")
1767                 .serviceId("openbasiskaart")
1768                 .layerName("osm")
1769                 .visible(true))
1770             .addLayerNodesItem(new AppTreeLevelNode()
1771                 .objectType("AppTreeLevelNode")
1772                 .id("root")
1773                 .root(true)
1774                 .title("Application layers")
1775                 .childrenIds(List.of(
1776                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel", "xpfhl34VmghkU12nP9Jer")))
1777             .addLayerNodesItem(new AppTreeLayerNode()
1778                 .objectType("AppTreeLayerNode")
1779                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1780                 .serviceId("snapshot-geoserver")
1781                 .layerName("postgis:kadastraal_perceel")
1782                 .visible(true))
1783             .addLayerNodesItem(new AppTreeLevelNode()
1784                 .id("xpfhl34VmghkU12nP9Jer")
1785                 .root(false)
1786                 .title("restricted")
1787                 .objectType("AppTreeLevelNode")
1788                 .childrenIds(List.of("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1789             .addLayerNodesItem(new AppTreeLayerNode()
1790                 .objectType("AppTreeLayerNode")
1791                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1792                 .visible(true)
1793                 .serviceId("filtered-snapshot-geoserver")
1794                 .layerName("postgis:begroeidterreindeel")))
1795         .setSettings(new AppSettings());
1796 
1797     applicationRepository.save(app);
1798 
1799     Configuration config = new Configuration();
1800     config.setKey(Configuration.DEFAULT_APP);
1801     config.setValue("default");
1802     configurationRepository.save(config);
1803     config = new Configuration();
1804     config.setKey(Configuration.DEFAULT_BASE_APP);
1805     config.setValue("base");
1806     configurationRepository.save(config);
1807   }
1808 
1809   private void createConfigurationTestData() throws JacksonException {
1810     Configuration config = new Configuration();
1811     config.setKey("test");
1812     config.setAvailableForViewer(true);
1813     config.setValue("test value");
1814     config.setJsonValue(new JsonMapper().readTree("{ \"someProperty\": 1, \"nestedObject\": { \"num\": 42 } }"));
1815     configurationRepository.save(config);
1816   }
1817 
1818   @Transactional
1819   public void createSolrIndex() throws Exception {
1820     if (connectToSpatialDbs) {
1821       // flush() the repo because we need to make sure feature type testdata is fully stored
1822       // before creating the Solr index (which requires access to the feature type settings)
1823       featureSourceRepository.flush();
1824 
1825       logger.info("Creating Solr index");
1826       @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
1827       final String solrUrl = "http://" + (connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "solr") + ":8983/solr/";
1828       this.solrService.setSolrUrl(solrUrl);
1829       SolrHelper solrHelper = new SolrHelper(this.solrService.getSolrClientForIndexing())
1830           .withBatchSize(solrBatchSize)
1831           .withGeometryValidationRule(solrGeometryValidationRule);
1832       GeoService geoService =
1833           geoServiceRepository.findById("snapshot-geoserver").orElseThrow();
1834       Application defaultApp = applicationRepository.findByName("default");
1835 
1836       TMFeatureType begroeidterreindeelFT = geoService.findFeatureTypeForLayer(
1837           geoService.findLayer("postgis:begroeidterreindeel"), featureSourceRepository);
1838 
1839       TMFeatureType wegdeelFT = geoService.findFeatureTypeForLayer(
1840           geoService.findLayer("sqlserver:wegdeel"), featureSourceRepository);
1841 
1842       TMFeatureType kadastraalPerceelFT = geoService.findFeatureTypeForLayer(
1843           geoService.findLayer("postgis:kadastraal_perceel"), featureSourceRepository);
1844 
1845       try (solrHelper) {
1846         SearchIndex begroeidterreindeelIndex = null;
1847         if (begroeidterreindeelFT != null) {
1848           begroeidterreindeelIndex = new SearchIndex()
1849               .setName("Begroeidterreindeel")
1850               .setFeatureTypeId(begroeidterreindeelFT.getId())
1851               .setSearchFieldsUsed(List.of("class", "plus_fysiekvoorkomen", "bronhouder"))
1852               .setSearchDisplayFieldsUsed(List.of("class", "plus_fysiekvoorkomen"));
1853           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1854           begroeidterreindeelIndex = solrHelper.addFeatureTypeIndex(
1855               begroeidterreindeelIndex,
1856               begroeidterreindeelFT,
1857               featureSourceFactoryHelper,
1858               searchIndexRepository);
1859           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1860         }
1861 
1862         SearchIndex kadastraalPerceelIndex = null;
1863         if (kadastraalPerceelFT != null) {
1864           kadastraalPerceelIndex = new SearchIndex()
1865               .setName("kadastraal_perceel")
1866               .setFeatureTypeId(kadastraalPerceelFT.getId())
1867               .setSearchFieldsUsed(List.of("aanduiding"))
1868               .setSearchDisplayFieldsUsed(List.of("aanduiding"));
1869           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1870           kadastraalPerceelIndex = solrHelper.addFeatureTypeIndex(
1871               kadastraalPerceelIndex,
1872               kadastraalPerceelFT,
1873               featureSourceFactoryHelper,
1874               searchIndexRepository);
1875           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1876         }
1877 
1878         SearchIndex wegdeelIndex = null;
1879         if (wegdeelFT != null) {
1880           wegdeelIndex = new SearchIndex()
1881               .setName("Wegdeel")
1882               .setFeatureTypeId(wegdeelFT.getId())
1883               .setSearchFieldsUsed(List.of(
1884                   "function_", "plus_fysiekvoorkomenwegdeel", "surfacematerial", "bronhouder"))
1885               .setSearchDisplayFieldsUsed(List.of("function_", "plus_fysiekvoorkomenwegdeel"));
1886           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1887           wegdeelIndex = solrHelper.addFeatureTypeIndex(
1888               wegdeelIndex, wegdeelFT, featureSourceFactoryHelper, searchIndexRepository);
1889           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1890         }
1891 
1892         featureSourceRepository
1893             .getByTitle("PostGIS")
1894             .flatMap(fs -> fs.getFeatureTypes().stream()
1895                 .filter(ft -> ft.getName().equals("bak"))
1896                 .findFirst())
1897             .ifPresent(ft -> {
1898               SearchIndex bak = new SearchIndex()
1899                   .setName("bak")
1900                   .setFeatureTypeId(ft.getId())
1901                   .setSearchFieldsUsed(List.of("gmlid", "identificatie", "plus_type"))
1902                   .setSearchDisplayFieldsUsed(List.of("gmlid", "plus_type", "bronhouder"));
1903               searchIndexRepository.save(bak);
1904               try {
1905                 bak = solrHelper.addFeatureTypeIndex(
1906                     bak, ft, featureSourceFactoryHelper, searchIndexRepository);
1907                 searchIndexRepository.save(bak);
1908               } catch (IOException | SolrServerException e) {
1909                 throw new RuntimeException(e);
1910               }
1911             });
1912 
1913         // creating a solr index of config this will/should fail because there is no primary key in
1914         // the FT
1915         featureSourceRepository
1916             .getByTitle("PostGIS OSM")
1917             .flatMap(fs -> fs.getFeatureTypes().stream()
1918                 .filter(ft -> ft.getName().equals("osm_roads"))
1919                 .findFirst())
1920             .ifPresent(ft -> {
1921               SearchIndex osm_no_pk = new SearchIndex()
1922                   .setName("osm_no_pk")
1923                   .setFeatureTypeId(ft.getId())
1924                   .setSearchFieldsUsed(List.of("landuse", "osm_id", "natural", "boundary"))
1925                   .setSearchDisplayFieldsUsed(
1926                       List.of("landuse", "osm_id", "natural", "amenity", "boundary"));
1927               searchIndexRepository.save(osm_no_pk);
1928             });
1929 
1930         AppTreeLayerNode begroeidTerreindeelLayerNode = defaultApp
1931             .getAllAppTreeLayerNode()
1932             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1933             .findFirst()
1934             .orElse(null);
1935 
1936         if (begroeidTerreindeelLayerNode != null && begroeidterreindeelIndex != null) {
1937           defaultApp
1938               .getAppLayerSettings(begroeidTerreindeelLayerNode)
1939               .setSearchIndexId(begroeidterreindeelIndex.getId());
1940         }
1941 
1942         AppTreeLayerNode kadastraalPerceelLayerNode = defaultApp
1943             .getAllAppTreeLayerNode()
1944             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1945             .findFirst()
1946             .orElse(null);
1947 
1948         if (kadastraalPerceelLayerNode != null && kadastraalPerceelIndex != null) {
1949           defaultApp
1950               .getAppLayerSettings(kadastraalPerceelLayerNode)
1951               .setSearchIndexId(kadastraalPerceelIndex.getId());
1952         }
1953 
1954         AppTreeLayerNode wegdeel = defaultApp
1955             .getAllAppTreeLayerNode()
1956             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:sqlserver:wegdeel"))
1957             .findFirst()
1958             .orElse(null);
1959 
1960         if (wegdeel != null && wegdeelIndex != null) {
1961           defaultApp.getAppLayerSettings(wegdeel).setSearchIndexId(wegdeelIndex.getId());
1962         }
1963 
1964         applicationRepository.save(defaultApp);
1965       }
1966     }
1967   }
1968 
1969   private void createSearchIndexTasks() {
1970     logger.info("Creating search index tasks");
1971     List.of("Begroeidterreindeel", "kadastraal_perceel")
1972         .forEach(name -> searchIndexRepository.findByName(name).ifPresent(index -> {
1973           index.setSchedule(new TaskSchedule()
1974               /* hour */
1975               .cronExpression("0 0 0/1 1/1 * ? *")
1976               // /* 15 min */
1977               // .cronExpression("0 0/15 * 1/1 * ? *")
1978               .description("Update Solr index \"" + name + "\" every hour"));
1979           try {
1980             final UUID uuid = taskManagerService.createTask(
1981                 IndexTask.class,
1982                 new TMJobDataMap(Map.of(
1983                     Task.TYPE_KEY,
1984                     TaskType.INDEX,
1985                     Task.DESCRIPTION_KEY,
1986                     index.getSchedule().getDescription(),
1987                     IndexTask.INDEX_KEY,
1988                     index.getId().toString(),
1989                     Task.PRIORITY_KEY,
1990                     10)),
1991                 index.getSchedule().getCronExpression());
1992 
1993             index.getSchedule().setUuid(uuid);
1994             searchIndexRepository.save(index);
1995 
1996             logger.info("Created task to update Solr index with key: {}", uuid);
1997           } catch (SchedulerException e) {
1998             logger.error("Error creating scheduled solr index task", e);
1999           }
2000         }));
2001   }
2002 
2003   private void createPages() throws IOException {
2004     Upload logo = new Upload()
2005         .setCategory(Upload.CATEGORY_PORTAL_IMAGE)
2006         .setFilename("gradient.svg")
2007         .setMimeType("image/svg+xml")
2008         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
2009         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2010     uploadRepository.save(logo);
2011 
2012     Page loggedIn = new Page();
2013     loggedIn.setAuthorizationRules(ruleLoggedIn);
2014     loggedIn.setName("loggedIn");
2015     loggedIn.setType("page");
2016     loggedIn.setContent("About Tailormap");
2017     loggedIn.setContent("""
2018 # About Tailormap
2019 This is a page for logged in users.
2020 """);
2021     pageRepository.save(loggedIn);
2022 
2023     Page about = new Page();
2024     about.setAuthorizationRules(ruleAnonymousRead);
2025     about.setName("about");
2026     about.setType("page");
2027     about.setContent("About Tailormap");
2028     about.setContent("""
2029 # About Tailormap
2030 
2031 This is a page about *Tailormap*. It doesn't say much yet.
2032 """);
2033     pageRepository.save(about);
2034 
2035     Page page = new Page();
2036     page.setAuthorizationRules(ruleAnonymousRead);
2037     page.setName("home");
2038     page.setType("page");
2039     page.setTitle("Tailormap - Home");
2040     page.setContent("""
2041 # Welcome to Tailormap!
2042 
2043 This page is only visible when you implement a frontend to display pages, or get it (including a simple CMS)
2044 from [B3Partners](https://www.b3partners.nl)!
2045 """);
2046     page.setClassName(null);
2047     page.setTiles(List.of(
2048         new PageTile()
2049             .authorizationRules(ruleAnonymousRead)
2050             .id(UUID.randomUUID().toString())
2051             .title("Default app")
2052             .tileType(PageTile.TileTypeEnum.APPLICATION)
2053             .applicationId(Optional.ofNullable(applicationRepository.findByName("default"))
2054                 .map(Application::getId)
2055                 .orElse(null))
2056             .image(logo.getId().toString())
2057             .content("*Default app* tile content")
2058             .filterRequireAuthorization(false)
2059             .openInNewWindow(false),
2060         new PageTile()
2061             .authorizationRules(ruleAnonymousRead)
2062             .id(UUID.randomUUID().toString())
2063             .title("Secured app")
2064             .tileType(PageTile.TileTypeEnum.APPLICATION)
2065             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
2066                 .map(Application::getId)
2067                 .orElse(null))
2068             .filterRequireAuthorization(true)
2069             .content("Secure app, only shown if user has authorization")
2070             .openInNewWindow(false),
2071         new PageTile()
2072             .authorizationRules(ruleAnonymousRead)
2073             .id(UUID.randomUUID().toString())
2074             .title("Secured app (unfiltered)")
2075             .tileType(PageTile.TileTypeEnum.APPLICATION)
2076             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
2077                 .map(Application::getId)
2078                 .orElse(null))
2079             .filterRequireAuthorization(false)
2080             .content("Secure app, tile shown to everyone")
2081             .openInNewWindow(false),
2082         new PageTile()
2083             .authorizationRules(ruleAnonymousRead)
2084             .id(UUID.randomUUID().toString())
2085             .title("About")
2086             .tileType(PageTile.TileTypeEnum.PAGE)
2087             .pageId(about.getId())
2088             .openInNewWindow(false),
2089         new PageTile()
2090             .authorizationRules(ruleAnonymousRead)
2091             .id(UUID.randomUUID().toString())
2092             .title("B3Partners")
2093             .tileType(PageTile.TileTypeEnum.URL)
2094             .url("https://www.b3partners.nl/")
2095             .openInNewWindow(true),
2096         new PageTile()
2097             .authorizationRules(ruleLoggedIn)
2098             .id(UUID.randomUUID().toString())
2099             .title("Github repository")
2100             .tileType(PageTile.TileTypeEnum.URL)
2101             .url("https://github.com/Tailormap/tailormap-viewer")
2102             .openInNewWindow(true),
2103         new PageTile()
2104             .authorizationRules(ruleAnonymousRead)
2105             .id(UUID.randomUUID().toString())
2106             .tileType(PageTile.TileTypeEnum.PAGE)
2107             .title("Secured page")
2108             .pageId(loggedIn.getId())
2109             .filterRequireAuthorization(true)
2110             .openInNewWindow(true),
2111         new PageTile()
2112             .authorizationRules(ruleAnonymousRead)
2113             .id(UUID.randomUUID().toString())
2114             .tileType(PageTile.TileTypeEnum.PAGE)
2115             .title("Secured page (unfiltered)")
2116             .pageId(loggedIn.getId())
2117             .filterRequireAuthorization(false)
2118             .openInNewWindow(true)));
2119     pageRepository.save(page);
2120 
2121     Configuration c = new Configuration();
2122     c.setKey(HOME_PAGE);
2123     c.setValue(page.getId().toString());
2124     configurationRepository.save(c);
2125 
2126     List<MenuItem> globalMenuItems = List.of(
2127         new MenuItem().pageId(about.getId()).label("About").openInNewWindow(false),
2128         new MenuItem()
2129             .label("B3Partners website")
2130             .url("https://www.b3partners.nl/")
2131             .openInNewWindow(true)
2132             .exclusiveOnPageId(about.getId()));
2133     c = new Configuration();
2134     c.setKey(PORTAL_MENU);
2135     c.setJsonValue(new JsonMapper().valueToTree(globalMenuItems));
2136     configurationRepository.save(c);
2137   }
2138 
2139   private void insertTestDrawing() {
2140     // note that the drawing uuid is hardcoded and used in the DrawingControllerIntegrationTest
2141     try {
2142       this.jdbcClient.sql("""
2143 INSERT INTO data.drawing (id,name,description,domain_data,"access",created_by,created_at,updated_by,updated_at,srid,"version") VALUES
2144 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid,'Testcase','A private access drawing that is inserted as part of the testdata','{"items": 1, "domain": "test drawings"}','private','tm-admin','2025-02-27 17:53:36.095164+01','tm-admin','2025-02-27 17:54:19.384961+01',28992,1);
2145 """).update();
2146 
2147       this.jdbcClient.sql("""
2148 INSERT INTO data.drawing_feature (drawing_id,id,geometry,properties) VALUES
2149 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '394e0d4a-b374-4d00-94c8-1758ea70e9d9'::uuid, 'SRID=28992;POLYGON ((131982.01 458929.27, 131957.31 458755.45, 132072.39 458736.69, 132099.06 458910.51, 131982.01 458929.27))'::public.geometry, '{"type": "POLYGON", "style": {"label": "", "marker": "circle", "fillColor": "rgb(117, 117, 117)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(98, 54, 255)", "strokeWidth": 3, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}}'::jsonb),
2150 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '89fc320c-41a3-4635-8d98-39d822339692'::uuid, 'SRID=28992;POINT (131897.55 458971.24)'::public.geometry, '{"type": "CIRCLE", "style": {"label": "CIRCLE", "marker": "circle", "fillColor": "rgb(117, 117, 117)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(98, 54, 255)", "strokeWidth": 3, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}, "radius": 14.989999999990687}'::jsonb),
2151 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '5d6252cc-25b6-4a43-9053-0779b86895a7'::uuid, 'SRID=28992;LINESTRING (131920.76 458754.96, 131923.73 458783.1, 131930.15 458793.97, 131945.95 458912.98, 131967.68 458946.06)'::public.geometry, '{"type": "LINE", "style": {"label": "", "marker": "circle", "fillColor": "rgb(255, 82, 82)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(255, 82, 82)", "strokeWidth": 13, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}}'::jsonb)
2152 """).update();
2153     } catch (Exception any) {
2154       logger.error("Error inserting test drawing in data schema, some tests may fail", any);
2155     }
2156   }
2157 
2158   private void insertDrawingStyle() throws IOException {
2159     Upload upload = new Upload()
2160         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
2161         .setMimeType("image/svg+xml")
2162         .setFilename("ISO_7001_PI_PF_007.svg")
2163         .setContent(new ClassPathResource("test/ISO_7001_PI_PF_007.svg").getContentAsByteArray())
2164         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2165     upload = uploadRepository.save(upload);
2166     UUID drinkwaterImageId = upload.getId();
2167 
2168     upload = new Upload()
2169         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
2170         .setMimeType("image/svg+xml")
2171         .setFilename("lichtpunt.svg")
2172         .setContent(new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray())
2173         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2174     upload = uploadRepository.save(upload);
2175     UUID lichtpuntImageId = upload.getId();
2176 
2177     upload = new Upload()
2178         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
2179         .setMimeType("image/svg+xml")
2180         .setFilename("ISO_7010_E003_-_First_aid_sign.svg")
2181         .setContent(new ClassPathResource("test/ISO_7010_E003_-_First_aid_sign.svg").getContentAsByteArray())
2182         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2183     upload = uploadRepository.save(upload);
2184     UUID firstAidImageId = upload.getId();
2185 
2186     Properties props = new Properties();
2187     props.putAll(Map.of(
2188         "water-uuid", drinkwaterImageId.toString(),
2189         "lichtpunt-uuid", lichtpuntImageId.toString(),
2190         "first-aid-uuid", firstAidImageId.toString()));
2191 
2192     String drawingStyles =
2193         new ClassPathResource("test/object-drawing-style.json").getContentAsString(StandardCharsets.UTF_8);
2194     drawingStyles = new PropertyPlaceholderHelper("${", "}").replacePlaceholders(drawingStyles, props);
2195 
2196     // save the updated drawing style
2197     upload = new Upload()
2198         .setCategory(Upload.CATEGORY_DRAWING_STYLE)
2199         .setMimeType("application/json")
2200         .setFilename("object-drawing-style.json")
2201         .setContent(drawingStyles.getBytes(StandardCharsets.UTF_8))
2202         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2203     uploadRepository.save(upload);
2204   }
2205 }