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     List<TMFeatureSource> testWFS = List.of(
666         //            "demo",
667         new TMFeatureSource()
668             .setProtocol(TMFeatureSource.Protocol.WFS)
669             .setUrl("https://demo.tailormap.com/geoserver/geodata/wfs")
670             .setTitle("WFS for Demo")
671             .setLinkedService(geoServiceRepository.findById("demo").orElseThrow()),
672         //            "snapshot-geoserver",
673         new TMFeatureSource()
674             .setProtocol(TMFeatureSource.Protocol.WFS)
675             .setUrl("https://snapshot.tailormap.nl/geoserver/wfs?")
676             .setTitle("WFS for Test GeoServer")
677             .setLinkedService(geoServiceRepository
678                 .findById("snapshot-geoserver")
679                 .orElseThrow()));
680     featureSourceRepository.saveAll(testWFS);
681 
682     CatalogNode wfsFeatureSourceCatalogNode =
683         new CatalogNode().id("wfs_feature_sources").title("WFS feature sources");
684     rootCatalogNode.addChildrenItem(wfsFeatureSourceCatalogNode.getId());
685     catalog.getNodes().add(wfsFeatureSourceCatalogNode);
686 
687     for (TMFeatureSource featureSource : testWFS) {
688       new WFSFeatureSourceHelper().loadCapabilities(featureSource);
689       wfsFeatureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
690           .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
691           .id(featureSource.getId().toString()));
692     }
693 
694     String geodataPassword = "980f1c8A-25933b2";
695 
696     Map<String, TMFeatureSource> featureSources = Map.of(
697         "postgis",
698         new TMFeatureSource()
699             .setProtocol(TMFeatureSource.Protocol.JDBC)
700             .setTitle("PostGIS")
701             .setJdbcConnection(new JDBCConnectionProperties()
702                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
703                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
704                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
705                 .database("geodata")
706                 .schema("public")
707                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
708             .setAuthentication(new ServiceAuthentication()
709                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
710                 .username("geodata")
711                 .password(geodataPassword)),
712         "postgis_osm",
713         new TMFeatureSource()
714             .setProtocol(TMFeatureSource.Protocol.JDBC)
715             .setTitle("PostGIS OSM")
716             .setJdbcConnection(new JDBCConnectionProperties()
717                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
718                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
719                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
720                 .database("geodata")
721                 .schema("osm")
722                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
723             .setAuthentication(new ServiceAuthentication()
724                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
725                 .username("geodata")
726                 .password(geodataPassword)),
727         "oracle",
728         new TMFeatureSource()
729             .setProtocol(TMFeatureSource.Protocol.JDBC)
730             .setTitle("Oracle")
731             .setJdbcConnection(new JDBCConnectionProperties()
732                 .dbtype(JDBCConnectionProperties.DbtypeEnum.ORACLE)
733                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "oracle")
734                 .database("/FREEPDB1")
735                 .schema("GEODATA")
736                 .additionalProperties(Map.of("connectionOptions", "?oracle.jdbc.J2EE13Compliant=true")))
737             .setAuthentication(new ServiceAuthentication()
738                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
739                 .username("geodata")
740                 .password(geodataPassword)),
741         "sqlserver",
742         new TMFeatureSource()
743             .setProtocol(TMFeatureSource.Protocol.JDBC)
744             .setTitle("MS SQL Server")
745             .setJdbcConnection(new JDBCConnectionProperties()
746                 .dbtype(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)
747                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "sqlserver")
748                 .database("geodata")
749                 .schema("dbo")
750                 .additionalProperties(Map.of("connectionOptions", ";encrypt=false")))
751             .setAuthentication(new ServiceAuthentication()
752                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
753                 .username("geodata")
754                 .password(geodataPassword)),
755         "pdok-kadaster-bestuurlijkegebieden",
756         new TMFeatureSource()
757             .setProtocol(TMFeatureSource.Protocol.WFS)
758             .setUrl(
759                 "https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wfs/v1_0?service=WFS&VERSION=2.0.0")
760             .setTitle("Bestuurlijke gebieden")
761             .setNotes(
762                 "Overzicht van de bestuurlijke indeling van Nederland in gemeenten en provincies alsmede de rijksgrens. Gegevens zijn afgeleid uit de Basisregistratie Kadaster (BRK)."));
763     featureSourceRepository.saveAll(featureSources.values());
764 
765     new WFSFeatureSourceHelper().loadCapabilities(featureSources.get("pdok-kadaster-bestuurlijkegebieden"));
766     geoServiceRepository.findById("pdok-kadaster-bestuurlijkegebieden").ifPresent(geoService -> {
767       geoService
768           .getSettings()
769           .getLayerSettings()
770           .put(
771               "Provinciegebied",
772               new GeoServiceLayerSettings()
773                   .description("The administrative boundary of Dutch Provinces, connected to a WFS.")
774                   .featureType(new FeatureTypeRef()
775                       .featureSourceId(featureSources
776                           .get("pdok-kadaster-bestuurlijkegebieden")
777                           .getId())
778                       .featureTypeName(PROVINCIE_FEATURE_TYPE_NAME))
779                   .title("Provinciegebied (WFS)"));
780       geoServiceRepository.save(geoService);
781     });
782 
783     geoServiceRepository.findById("bestuurlijkegebieden-proxied").ifPresent(geoService -> {
784       geoService
785           .getSettings()
786           .getLayerSettings()
787           .put(
788               "Provinciegebied",
789               new GeoServiceLayerSettings()
790                   .featureType(new FeatureTypeRef()
791                       .featureSourceId(featureSources
792                           .get("pdok-kadaster-bestuurlijkegebieden")
793                           .getId())
794                       .featureTypeName(PROVINCIE_FEATURE_TYPE_NAME))
795                   .title("Provinciegebied (WFS, proxied met auth)"));
796       geoServiceRepository.save(geoService);
797     });
798 
799     CatalogNode featureSourceCatalogNode =
800         new CatalogNode().id("feature_sources").title("Test feature sources");
801     rootCatalogNode.addChildrenItem(featureSourceCatalogNode.getId());
802     catalog.getNodes().add(featureSourceCatalogNode);
803 
804     for (TMFeatureSource featureSource : featureSources.values()) {
805       featureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
806           .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
807           .id(featureSource.getId().toString()));
808     }
809     catalogRepository.save(catalog);
810 
811     if (connectToSpatialDbs) {
812       featureSources.values().forEach(fs -> {
813         try {
814           if (fs.getProtocol() == TMFeatureSource.Protocol.JDBC) {
815             new JDBCFeatureSourceHelper().loadCapabilities(fs);
816           } else if (fs.getProtocol() == TMFeatureSource.Protocol.WFS) {
817             new WFSFeatureSourceHelper().loadCapabilities(fs);
818           }
819         } catch (Exception e) {
820           logger.error("Error loading capabilities for feature source {}", fs.getTitle(), e);
821         }
822       });
823 
824       services.stream()
825           // Set layer settings for both the proxied and non-proxied one, but don't overwrite the
826           // authorization rules for the "filtered-snapshot-geoserver" service
827           .filter(s -> s.getId().startsWith("snapshot-geoserver"))
828           .forEach(s -> s.getSettings()
829               .layerSettings(Map.of(
830                   "postgis:begroeidterreindeel",
831                   new GeoServiceLayerSettings()
832                       .description("""
833 This layer shows data from https://www.postgis.net/
834 
835 https://postgis.net/brand.svg""")
836                       .featureType(new FeatureTypeRef()
837                           .featureSourceId(featureSources
838                               .get("postgis")
839                               .getId())
840                           .featureTypeName("begroeidterreindeel")),
841                   "postgis:bak",
842                   new GeoServiceLayerSettings()
843                       .featureType(new FeatureTypeRef()
844                           .featureSourceId(featureSources
845                               .get("postgis")
846                               .getId())
847                           .featureTypeName("bak")),
848                   "postgis:kadastraal_perceel",
849                   new GeoServiceLayerSettings()
850                       .description("cadastral parcel label points")
851                       .featureType(new FeatureTypeRef()
852                           .featureSourceId(featureSources
853                               .get("postgis")
854                               .getId())
855                           .featureTypeName("kadastraal_perceel")),
856                   "sqlserver:wegdeel",
857                   new GeoServiceLayerSettings()
858                       .attribution(
859                           "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)")
860                       .description("""
861 This layer shows data from [MS SQL Server](https://learn.microsoft.com/en-us/sql/relational-databases/spatial/spatial-data-sql-server).
862 
863 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""")
864                       .featureType(new FeatureTypeRef()
865                           .featureSourceId(featureSources
866                               .get("sqlserver")
867                               .getId())
868                           .featureTypeName("wegdeel")),
869                   "oracle:WATERDEEL",
870                   new GeoServiceLayerSettings()
871                       .description("This layer shows data from Oracle Spatial.")
872                       .featureType(new FeatureTypeRef()
873                           .featureSourceId(featureSources
874                               .get("oracle")
875                               .getId())
876                           .featureTypeName("WATERDEEL")),
877                   "postgis:osm_polygon",
878                   new GeoServiceLayerSettings()
879                       .description("This layer shows OSM data from postgis.")
880                       .featureType(new FeatureTypeRef()
881                           .featureSourceId(featureSources
882                               .get("postgis_osm")
883                               .getId())
884                           .featureTypeName("osm_polygon")))));
885     }
886 
887     featureSources.get("pdok-kadaster-bestuurlijkegebieden").getFeatureTypes().stream()
888         .filter(ft -> ft.getName().equals(PROVINCIE_FEATURE_TYPE_NAME))
889         .findFirst()
890         .ifPresent(ft -> {
891           ft.getSettings().addHideAttributesItem("identificatie");
892           ft.getSettings().addHideAttributesItem("ligtInLandCode");
893           ft.getSettings().addHideAttributesItem("fuuid");
894           ft.getSettings().putAttributeSettingsItem("naam", new AttributeSettings().title("Naam"));
895           ft.getSettings()
896               .setTemplate(new FeatureTypeTemplate()
897                   .templateLanguage("simple")
898                   .markupLanguage("markdown")
899                   .template("""
900 ### Provincie
901 Deze provincie heet **{{naam}}** en ligt in _{{ligtInLandNaam}}_.
902 
903 | Attribuut | Waarde             |
904 | --------- | ------------------ |
905 | `code`    | {{code}}           |
906 | `naam`    | {{naam}}           |
907 | `ligt in` | {{ligtInLandNaam}} |"""));
908         });
909 
910     featureSources.get("postgis").getFeatureTypes().stream()
911         .filter(ft -> ft.getName().equals("begroeidterreindeel"))
912         .findFirst()
913         .ifPresent(ft -> {
914           ft.getSettings().addHideAttributesItem("terminationdate");
915           ft.getSettings().addHideAttributesItem("geom_kruinlijn");
916           ft.getSettings().putAttributeSettingsItem("gmlid", new AttributeSettings().title("GML ID"));
917           ft.getSettings()
918               .putAttributeSettingsItem("identificatie", new AttributeSettings().title("Identificatie"));
919           ft.getSettings()
920               .putAttributeSettingsItem(
921                   "tijdstipregistratie", new AttributeSettings().title("Registratie"));
922           ft.getSettings()
923               .putAttributeSettingsItem(
924                   "eindregistratie", new AttributeSettings().title("Eind registratie"));
925           ft.getSettings().putAttributeSettingsItem("class", new AttributeSettings().title("Klasse"));
926           ft.getSettings()
927               .putAttributeSettingsItem("bronhouder", new AttributeSettings().title("Bronhouder"));
928           ft.getSettings()
929               .putAttributeSettingsItem("inonderzoek", new AttributeSettings().title("In onderzoek"));
930           ft.getSettings()
931               .putAttributeSettingsItem(
932                   "relatievehoogteligging", new AttributeSettings().title("Relatieve hoogteligging"));
933           ft.getSettings()
934               .putAttributeSettingsItem("bgt_status", new AttributeSettings().title("BGT status"));
935           ft.getSettings()
936               .putAttributeSettingsItem("plus_status", new AttributeSettings().title("Plus-status"));
937           ft.getSettings()
938               .putAttributeSettingsItem(
939                   "plus_fysiekvoorkomen", new AttributeSettings().title("Plus-fysiek voorkomen"));
940           ft.getSettings()
941               .putAttributeSettingsItem(
942                   "begroeidterreindeeloptalud", new AttributeSettings().title("Op talud"));
943           ft.getSettings().addAttributeOrderItem("identificatie");
944           ft.getSettings().addAttributeOrderItem("bronhouder");
945           ft.getSettings().addAttributeOrderItem("class");
946           ft.getSettings()
947               .addAttachmentAttributesItem(new AttachmentAttributeType()
948                   .attributeName("bijlage")
949                   .maxAttachmentSize(4_000_000L)
950                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
951           try {
952             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
953           } catch (IOException | SQLException e) {
954             throw new RuntimeException("Failed to create attachments table", e);
955           }
956           ft.getSettings().addEditableAttributesItem("identificatie");
957           ft.getSettings().addEditableAttributesItem("bronhouder");
958           ft.getSettings().addEditableAttributesItem("class");
959           ft.getSettings().addEditableAttributesItem("gmlid");
960           ft.getSettings().addEditableAttributesItem("lv_publicatiedatum");
961           ft.getSettings().addEditableAttributesItem("creationdate");
962           ft.getSettings().addEditableAttributesItem("tijdstipregistratie");
963           ft.getSettings().addEditableAttributesItem("eindregistratie");
964           ft.getSettings().addEditableAttributesItem("terminationdate");
965           ft.getSettings().addEditableAttributesItem("inonderzoek");
966           ft.getSettings().addEditableAttributesItem("relatievehoogteligging");
967           ft.getSettings().addEditableAttributesItem("bgt_status");
968           ft.getSettings().addEditableAttributesItem("plus_status");
969           ft.getSettings().addEditableAttributesItem("plus_fysiekvoorkomen");
970           ft.getSettings().addEditableAttributesItem("begroeidterreindeeloptalud");
971         });
972 
973     featureSources.get("postgis").getFeatureTypes().stream()
974         .filter(ft -> ft.getName().equals("bak"))
975         .findFirst()
976         .ifPresent(ft -> {
977           ft.getSettings().addHideAttributesItem("gmlid");
978           ft.getSettings().addHideAttributesItem("lv_publicatiedatum");
979           ft.getSettings().addHideAttributesItem("creationdate");
980           ft.getSettings().addHideAttributesItem("tijdstipregistratie");
981           ft.getSettings().addHideAttributesItem("eindregistratie");
982           ft.getSettings().addHideAttributesItem("terminationdate");
983           ft.getSettings().addHideAttributesItem("inonderzoek");
984           ft.getSettings().addHideAttributesItem("relatievehoogteligging");
985           ft.getSettings().addHideAttributesItem("bgt_status");
986           ft.getSettings().addHideAttributesItem("plus_status");
987           ft.getSettings().addHideAttributesItem("function_");
988           ft.getSettings().addHideAttributesItem("plus_type");
989         });
990 
991     featureSources.get("oracle").getFeatureTypes().stream()
992         .filter(ft -> ft.getName().equals("WATERDEEL"))
993         .findFirst()
994         .ifPresent(ft -> {
995           ft.getSettings()
996               .addAttachmentAttributesItem(new AttachmentAttributeType()
997                   .attributeName("bijlage")
998                   .maxAttachmentSize(4_000_000L)
999                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1000           try {
1001             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1002           } catch (IOException | SQLException e) {
1003             throw new RuntimeException("Failed to create attachments table", e);
1004           }
1005           ft.getSettings().addEditableAttributesItem("GMLID");
1006           ft.getSettings().addEditableAttributesItem("IDENTIFICATIE");
1007           ft.getSettings().addEditableAttributesItem("LV_PUBLICATIEDATUM");
1008           ft.getSettings().addEditableAttributesItem("CREATIONDATE");
1009           ft.getSettings().addEditableAttributesItem("TIJDSTIPREGISTRATIE");
1010           ft.getSettings().addEditableAttributesItem("EINDREGISTRATIE");
1011           ft.getSettings().addEditableAttributesItem("TERMINATIONDATE");
1012           ft.getSettings().addEditableAttributesItem("BRONHOUDER");
1013           ft.getSettings().addEditableAttributesItem("INONDERZOEK");
1014           ft.getSettings().addEditableAttributesItem("RELATIEVEHOOGTELIGGING");
1015           ft.getSettings().addEditableAttributesItem("BGT_STATUS");
1016           ft.getSettings().addEditableAttributesItem("PLUS_STATUS");
1017           ft.getSettings().addEditableAttributesItem("CLASS");
1018           ft.getSettings().addEditableAttributesItem("PLUS_TYPE");
1019         });
1020     featureSources.get("sqlserver").getFeatureTypes().stream()
1021         .filter(ft -> ft.getName().equals("wegdeel"))
1022         .findFirst()
1023         .ifPresent(ft -> {
1024           ft.getSettings()
1025               .addAttachmentAttributesItem(new AttachmentAttributeType()
1026                   .attributeName("bijlage")
1027                   .maxAttachmentSize(4_000_000L)
1028                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1029           try {
1030             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1031           } catch (IOException | SQLException e) {
1032             throw new RuntimeException("Failed to create attachments table", e);
1033           }
1034           ft.getSettings().addEditableAttributesItem("gmlid");
1035           ft.getSettings().addEditableAttributesItem("identificatie");
1036           ft.getSettings().addEditableAttributesItem("lv_publicatiedatum");
1037           ft.getSettings().addEditableAttributesItem("creationdate");
1038           ft.getSettings().addEditableAttributesItem("tijdstipregistratie");
1039           ft.getSettings().addEditableAttributesItem("eindregistratie");
1040           ft.getSettings().addEditableAttributesItem("terminationdate");
1041           ft.getSettings().addEditableAttributesItem("bronhouder");
1042           ft.getSettings().addEditableAttributesItem("inonderzoek");
1043           ft.getSettings().addEditableAttributesItem("relatievehoogteligging");
1044           ft.getSettings().addEditableAttributesItem("bgt_status");
1045           ft.getSettings().addEditableAttributesItem("plus_status");
1046           ft.getSettings().addEditableAttributesItem("function_");
1047           ft.getSettings().addEditableAttributesItem("plus_functiewegdeel");
1048           ft.getSettings().addEditableAttributesItem("plus_fysiekvoorkomenwegdeel");
1049           ft.getSettings().addEditableAttributesItem("surfacematerial");
1050           ft.getSettings().addEditableAttributesItem("wegdeeloptalud");
1051         });
1052 
1053     featureSources.get("postgis").getFeatureTypes().stream()
1054         .filter(ft -> ft.getName().equals("kadastraal_perceel"))
1055         .findFirst()
1056         .ifPresent(ft -> {
1057           ft.getSettings()
1058               .addAttachmentAttributesItem(new AttachmentAttributeType()
1059                   .attributeName("bijlage")
1060                   .maxAttachmentSize(4_000_000L)
1061                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1062           try {
1063             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1064           } catch (IOException | SQLException e) {
1065             throw new RuntimeException("Failed to create attachments table for kadastraal_perceel", e);
1066           }
1067           // hide primary key
1068           ft.getSettings().addHideAttributesItem("gml_id");
1069           // make some attributes editable
1070           ft.getSettings().addEditableAttributesItem("begin_geldigheid");
1071           ft.getSettings().addEditableAttributesItem("tijdstip_registratie");
1072         });
1073 
1074     featureSources.get("postgis_osm").getFeatureTypes().stream()
1075         .filter(ft -> ft.getName().equals("osm_polygon"))
1076         .findFirst()
1077         .ifPresent(ft -> {
1078           ft.getSettings().addEditableAttributesItem("osm_id");
1079           ft.getSettings().addEditableAttributesItem("building");
1080           ft.getSettings().addEditableAttributesItem("z_order");
1081         });
1082   }
1083 
1084   public void createAppTestData() throws Exception {
1085     Upload logo = new Upload()
1086         .setCategory(Upload.CATEGORY_APP_LOGO)
1087         .setFilename("gradient.svg")
1088         .setMimeType("image/svg+xml")
1089         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
1090         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1091     uploadRepository.save(logo);
1092 
1093     List<AppTreeNode> baseNodes = List.of(
1094         new AppTreeLayerNode()
1095             .objectType("AppTreeLayerNode")
1096             .id("lyr:openbasiskaart:osm")
1097             .serviceId("openbasiskaart")
1098             .layerName("osm")
1099             .visible(true),
1100         new AppTreeLayerNode()
1101             .objectType("AppTreeLayerNode")
1102             .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1103             .serviceId("pdok-hwh-luchtfotorgb")
1104             .layerName("Actueel_orthoHR")
1105             .visible(false));
1106 
1107     Application app = new Application()
1108         .setName("default")
1109         .setTitle("Tailormap demo")
1110         .setCrs("EPSG:28992")
1111         .setAuthorizationRules(ruleAnonymousRead)
1112         .setComponents(List.of(
1113             new Component()
1114                 .type("SIMPLE_SEARCH")
1115                 .config(new ComponentConfig()
1116                     .enabled(true)
1117                     .putAdditionalProperty("municipalities", List.of("0344"))),
1118             new Component().type("EDIT").config(new ComponentConfig().enabled(true)),
1119             new Component()
1120                 .type("TOC")
1121                 .config(new ComponentConfig()
1122                     .enabled(true)
1123                     .putAdditionalProperty("showEditLayerIcon", true)),
1124             new Component()
1125                 .type("COORDINATE_LINK_WINDOW")
1126                 .config(new ComponentConfig()
1127                     .enabled(true)
1128                     .putAdditionalProperty(
1129                         "urls",
1130                         List.of(
1131                             Map.of(
1132                                 "id",
1133                                 "google-maps",
1134                                 "url",
1135                                 "https://www.google.com/maps/@[lat],[lon],18z",
1136                                 "alias",
1137                                 "Google Maps",
1138                                 "projection",
1139                                 "EPSG:4326"),
1140                             Map.of(
1141                                 "id",
1142                                 "tm-demo",
1143                                 "url",
1144                                 "https://demo.tailormap.com/#@[X],[Y],18",
1145                                 "alias",
1146                                 "Tailormap demo",
1147                                 "projection",
1148                                 "EPSG:28992"))))))
1149         .setContentRoot(new AppContent()
1150             .addBaseLayerNodesItem(new AppTreeLevelNode()
1151                 .objectType("AppTreeLevelNode")
1152                 .id("root-base-layers")
1153                 .root(true)
1154                 .title("Base layers")
1155                 .childrenIds(List.of(
1156                     "lyr:openbasiskaart:osm",
1157                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1158                     "lyr:openbasiskaart-proxied:osm",
1159                     "lyr:openbasiskaart-tms:xyz",
1160                     "lyr:b3p-mapproxy-luchtfoto:xyz")))
1161             .addBaseLayerNodesItem(
1162                 // This layer from a secured proxied service should not be proxyable in a
1163                 // public app, see test_wms_secured_proxy_not_in_public_app() testcase
1164                 new AppTreeLayerNode()
1165                     .objectType("AppTreeLayerNode")
1166                     .id("lyr:openbasiskaart-proxied:osm")
1167                     .serviceId("openbasiskaart-proxied")
1168                     .layerName("osm")
1169                     .visible(false))
1170             .addBaseLayerNodesItem(new AppTreeLayerNode()
1171                 .objectType("AppTreeLayerNode")
1172                 .id("lyr:openbasiskaart-tms:xyz")
1173                 .serviceId("openbasiskaart-tms")
1174                 .layerName("xyz")
1175                 .visible(false))
1176             .addBaseLayerNodesItem(new AppTreeLayerNode()
1177                 .objectType("AppTreeLayerNode")
1178                 .id("lyr:b3p-mapproxy-luchtfoto:xyz")
1179                 .serviceId("b3p-mapproxy-luchtfoto")
1180                 .layerName("xyz")
1181                 .visible(false))
1182             .addLayerNodesItem(new AppTreeLevelNode()
1183                 .objectType("AppTreeLevelNode")
1184                 .id("root")
1185                 .root(true)
1186                 .title("Layers")
1187                 .childrenIds(List.of(
1188                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1189                     "lyr:bestuurlijkegebieden-proxied:Provinciegebied",
1190                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1191                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1192                     "lyr:snapshot-geoserver:postgis:bak",
1193                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1194                     "lyr:snapshot-geoserver:sqlserver:wegdeel",
1195                     "lyr:snapshot-geoserver:oracle:WATERDEEL",
1196                     "lyr:snapshot-geoserver:BGT",
1197                     "lvl:proxied",
1198                     "lvl:osm",
1199                     "lvl:archeo")))
1200             .addLayerNodesItem(new AppTreeLayerNode()
1201                 .objectType("AppTreeLayerNode")
1202                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1203                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1204                 .layerName("Provinciegebied")
1205                 .visible(true))
1206             // This is a layer from proxied service with auth that should also not be
1207             // visible, but it has a feature source attached, should also be denied for
1208             // features access and not be included in TOC
1209             .addLayerNodesItem(new AppTreeLayerNode()
1210                 .objectType("AppTreeLayerNode")
1211                 .id("lyr:bestuurlijkegebieden-proxied:Provinciegebied")
1212                 .serviceId("bestuurlijkegebieden-proxied")
1213                 .layerName("Provinciegebied")
1214                 .visible(false))
1215             .addLayerNodesItem(new AppTreeLayerNode()
1216                 .objectType("AppTreeLayerNode")
1217                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1218                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1219                 .layerName("Gemeentegebied")
1220                 .visible(true))
1221             .addLayerNodesItem(new AppTreeLayerNode()
1222                 .objectType("AppTreeLayerNode")
1223                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1224                 .serviceId("snapshot-geoserver")
1225                 .layerName("postgis:begroeidterreindeel")
1226                 .visible(true))
1227             .addLayerNodesItem(new AppTreeLayerNode()
1228                 .objectType("AppTreeLayerNode")
1229                 .id("lyr:snapshot-geoserver:postgis:bak")
1230                 .serviceId("snapshot-geoserver")
1231                 .layerName("postgis:bak")
1232                 .visible(false))
1233             .addLayerNodesItem(new AppTreeLayerNode()
1234                 .objectType("AppTreeLayerNode")
1235                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1236                 .serviceId("snapshot-geoserver")
1237                 .layerName("postgis:kadastraal_perceel")
1238                 .visible(false))
1239             .addLayerNodesItem(new AppTreeLayerNode()
1240                 .objectType("AppTreeLayerNode")
1241                 .id("lyr:snapshot-geoserver:sqlserver:wegdeel")
1242                 .serviceId("snapshot-geoserver")
1243                 .layerName("sqlserver:wegdeel")
1244                 .visible(true))
1245             .addLayerNodesItem(new AppTreeLayerNode()
1246                 .objectType("AppTreeLayerNode")
1247                 .id("lyr:snapshot-geoserver:oracle:WATERDEEL")
1248                 .serviceId("snapshot-geoserver")
1249                 .layerName("oracle:WATERDEEL")
1250                 .visible(true))
1251             .addLayerNodesItem(new AppTreeLayerNode()
1252                 .objectType("AppTreeLayerNode")
1253                 .id("lyr:snapshot-geoserver:BGT")
1254                 .serviceId("snapshot-geoserver")
1255                 .layerName("BGT")
1256                 .visible(false))
1257             .addLayerNodesItem(new AppTreeLevelNode()
1258                 .objectType("AppTreeLevelNode")
1259                 .id("lvl:proxied")
1260                 .title("Proxied")
1261                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1262             .addLayerNodesItem(new AppTreeLayerNode()
1263                 .objectType("AppTreeLayerNode")
1264                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1265                 .serviceId("snapshot-geoserver-proxied")
1266                 .layerName("postgis:begroeidterreindeel")
1267                 .visible(false))
1268             .addLayerNodesItem(new AppTreeLevelNode()
1269                 .objectType("AppTreeLevelNode")
1270                 .id("lvl:osm")
1271                 .title("OSM")
1272                 .childrenIds(List.of("lyr:snapshot-geoserver:postgis:osm_polygon")))
1273             .addLayerNodesItem(new AppTreeLayerNode()
1274                 .objectType("AppTreeLayerNode")
1275                 .id("lyr:snapshot-geoserver:postgis:osm_polygon")
1276                 .serviceId("snapshot-geoserver")
1277                 .layerName("postgis:osm_polygon")
1278                 .visible(false))
1279             .addLayerNodesItem(new AppTreeLevelNode()
1280                 .objectType("AppTreeLevelNode")
1281                 .id("lvl:archeo")
1282                 .title("Archeology")
1283                 .childrenIds(List.of("lyr:demo:geomorfologie")))
1284             .addLayerNodesItem(new AppTreeLayerNode()
1285                 .objectType("AppTreeLayerNode")
1286                 .id("lyr:demo:geomorfologie")
1287                 .serviceId("demo")
1288                 .layerName("geomorfologie")
1289                 .visible(true)))
1290         .setStyling(new AppStyling().logo(logo.getId().toString()))
1291         .setSettings(new AppSettings()
1292             .putLayerSettingsItem("lyr:openbasiskaart:osm", new AppLayerSettings().title("Openbasiskaart"))
1293             .putLayerSettingsItem(
1294                 "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", new AppLayerSettings().title("Luchtfoto"))
1295             .putLayerSettingsItem(
1296                 "lyr:openbasiskaart-proxied:osm",
1297                 new AppLayerSettings().title("Openbasiskaart (proxied)"))
1298             .putLayerSettingsItem(
1299                 "lyr:snapshot-geoserver:oracle:WATERDEEL",
1300                 new AppLayerSettings()
1301                     .opacity(50)
1302                     .title("Waterdeel overridden title")
1303                     .editable(true)
1304                     .description("This is the layer description from the app layer setting.")
1305                     .attribution(
1306                         "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)"))
1307             .putLayerSettingsItem(
1308                 "lyr:snapshot-geoserver:postgis:osm_polygon",
1309                 new AppLayerSettings()
1310                     .description("OpenStreetMap polygon data in EPSG:3857")
1311                     .opacity(60)
1312                     .editable(true)
1313                     .title("OSM Polygon (EPSG:3857)")
1314                     .attribution(
1315                         "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors"))
1316             .putLayerSettingsItem(
1317                 "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1318                 new AppLayerSettings()
1319                     .editable(true)
1320                     .addHideAttributesItem("begroeidterreindeeloptalud")
1321                     .addReadOnlyAttributesItem("eindregistratie")
1322                     .selectedStyles(
1323                         List.of(
1324                             new WMSStyle()
1325                                 .name("begroeidterreindeel")
1326                                 .title("Visualisatie van de begroeide terreindelen")
1327                                 .abstractText(
1328                                     "Deze stylesheet bevat de regels voor de visualisatie van het objecttype Begroeid Terreindeel")
1329                                 .legendUrl(
1330                                     URI.create(
1331                                         "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")),
1332                             new WMSStyle()
1333                                 .name("purple_polygon")
1334                                 .title("purple_polygon")
1335                                 .abstractText(null)
1336                                 .legendUrl(
1337                                     URI.create(
1338                                         "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")))))
1339             .putLayerSettingsItem(
1340                 "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1341                 new AppLayerSettings().editable(true).addReadOnlyAttributesItem("aanduiding"))
1342             .putLayerSettingsItem(
1343                 "lyr:snapshot-geoserver:sqlserver:wegdeel", new AppLayerSettings().editable(true))
1344             .putLayerSettingsItem(
1345                 "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1346                 new AppLayerSettings().editable(false))
1347             .putLayerSettingsItem(
1348                 "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1349                 new AppLayerSettings()
1350                     .hiddenFunctionality(Set.of(FEATURE_INFO, ATTRIBUTE_LIST, EXPORT)))
1351             .addFilterGroupsItem(new FilterGroup()
1352                 .id("filtergroup1")
1353                 .source("PRESET")
1354                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1355                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1356                 .operator(FilterGroup.OperatorEnum.AND)
1357                 .addFiltersItem(new Filter()
1358                     .id("filter1")
1359                     .type(Filter.TypeEnum.ATTRIBUTE)
1360                     .condition(Filter.ConditionEnum.BEFORE)
1361                     .addValueItem("2025-06-05")
1362                     .attribute("creationdate")
1363                     .attributeType(Filter.AttributeTypeEnum.DATE))
1364                 .addFiltersItem(new Filter()
1365                     .id("filter2")
1366                     .type(Filter.TypeEnum.ATTRIBUTE)
1367                     .condition(Filter.ConditionEnum.UNIQUE_VALUES)
1368                     .addValueItem("bodembedekkers")
1369                     .addValueItem("bosplantsoen")
1370                     .addValueItem("gras- en kruidachtigen")
1371                     .attribute("plus_fysiekvoorkomen")
1372                     .attributeType(Filter.AttributeTypeEnum.STRING)
1373                     .editConfiguration(new FilterEditConfiguration()
1374                         .filterTool(FilterEditConfiguration.FilterToolEnum.CHECKBOX)
1375                         .attributeValuesSettings(List.of(
1376                             new AttributeValueSettings()
1377                                 .value("bodembedekkers")
1378                                 .initiallySelected(true)
1379                                 .selectable(true)
1380                                 .alias("Bodembedekkers"),
1381                             new AttributeValueSettings()
1382                                 .value("bosplantsoen")
1383                                 .initiallySelected(true)
1384                                 .selectable(true)
1385                                 .alias("Bosplantsoen"),
1386                             new AttributeValueSettings()
1387                                 .value("gras- en kruidachtigen")
1388                                 .initiallySelected(true)
1389                                 .selectable(true)
1390                                 .alias("Gras- en kruidachtigen"),
1391                             new AttributeValueSettings()
1392                                 .value("griend en hakhout")
1393                                 .initiallySelected(false)
1394                                 .selectable(true),
1395                             new AttributeValueSettings()
1396                                 .value("heesters")
1397                                 .initiallySelected(false)
1398                                 .selectable(true),
1399                             new AttributeValueSettings()
1400                                 .value("planten")
1401                                 .initiallySelected(false)
1402                                 .selectable(true),
1403                             new AttributeValueSettings()
1404                                 .value("struikrozen")
1405                                 .initiallySelected(false)
1406                                 .selectable(true),
1407                             new AttributeValueSettings()
1408                                 .value("waardeOnbekend")
1409                                 .initiallySelected(false)
1410                                 .selectable(true))))))
1411             .addFilterGroupsItem(new FilterGroup()
1412                 .id("filtergroup2")
1413                 .source("PRESET")
1414                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1415                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1416                 .operator(FilterGroup.OperatorEnum.AND)
1417                 .addFiltersItem(new Filter()
1418                     .id("filter3")
1419                     .type(Filter.TypeEnum.ATTRIBUTE)
1420                     .condition(Filter.ConditionEnum.u)
1421                     .addValueItem("1")
1422                     .addValueItem("12419")
1423                     .attribute("perceelnummer")
1424                     .attributeType(Filter.AttributeTypeEnum.DOUBLE)
1425                     .editConfiguration(new FilterEditConfiguration()
1426                         .filterTool(FilterEditConfiguration.FilterToolEnum.SLIDER)
1427                         .initialLowerValue(1d)
1428                         .initialUpperValue(12419d)
1429                         .minimumValue(1d)
1430                         .maximumValue(12419d)))));
1431 
1432     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1433     app.setInitialExtent(
1434         new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d));
1435     app.setMaxExtent(new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d));
1436 
1437     if (map5url != null) {
1438       AppTreeLevelNode root =
1439           (AppTreeLevelNode) app.getContentRoot().getBaseLayerNodes().getFirst();
1440       List<String> childrenIds = new ArrayList<>(root.getChildrenIds());
1441       childrenIds.add("lyr:map5:map5topo");
1442       childrenIds.add("lyr:map5:map5topo_simple");
1443       childrenIds.add("lvl:luchtfoto-labels");
1444       root.setChildrenIds(childrenIds);
1445       app.getSettings()
1446           .putLayerSettingsItem("lyr:map5:map5topo", new AppLayerSettings().title("Map5"))
1447           .putLayerSettingsItem("lyr:map5:map5topo_simple", new AppLayerSettings().title("Map5 simple"));
1448       app.getContentRoot()
1449           .addBaseLayerNodesItem(new AppTreeLayerNode()
1450               .objectType("AppTreeLayerNode")
1451               .id("lyr:map5:map5topo")
1452               .serviceId("map5")
1453               .layerName("map5topo")
1454               .visible(false))
1455           .addBaseLayerNodesItem(new AppTreeLayerNode()
1456               .objectType("AppTreeLayerNode")
1457               .id("lyr:map5:map5topo_simple")
1458               .serviceId("map5")
1459               .layerName("map5topo_simple")
1460               .visible(false))
1461           .addBaseLayerNodesItem(new AppTreeLevelNode()
1462               .objectType("AppTreeLevelNode")
1463               .id("lvl:luchtfoto-labels")
1464               .title("Luchtfoto met labels")
1465               .addChildrenIdsItem("lyr:map5:luforoadslabels")
1466               .addChildrenIdsItem("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2"))
1467           .addBaseLayerNodesItem(new AppTreeLayerNode()
1468               .objectType("AppTreeLayerNode")
1469               .id("lyr:map5:luforoadslabels")
1470               .serviceId("map5")
1471               .layerName("luforoadslabels")
1472               .visible(false))
1473           .addBaseLayerNodesItem(new AppTreeLayerNode()
1474               .objectType("AppTreeLayerNode")
1475               .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2")
1476               .serviceId("pdok-hwh-luchtfotorgb")
1477               .layerName("Actueel_orthoHR")
1478               .visible(false));
1479     }
1480 
1481     applicationRepository.save(app);
1482 
1483     app = new Application()
1484         .setName("base")
1485         .setTitle("Service base app")
1486         .setCrs("EPSG:28992")
1487         .setAuthorizationRules(ruleAnonymousRead)
1488         .setContentRoot(new AppContent()
1489             .addBaseLayerNodesItem(new AppTreeLevelNode()
1490                 .objectType("AppTreeLevelNode")
1491                 .id("root-base-layers")
1492                 .root(true)
1493                 .title("Base layers")
1494                 .childrenIds(List.of(
1495                     "lyr:openbasiskaart:osm", "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR"))));
1496     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1497     applicationRepository.save(app);
1498 
1499     app = new Application()
1500         .setName("secured")
1501         .setTitle("secured app")
1502         .setCrs("EPSG:28992")
1503         .setAuthorizationRules(ruleLoggedIn)
1504         .setContentRoot(new AppContent()
1505             .addBaseLayerNodesItem(new AppTreeLevelNode()
1506                 .objectType("AppTreeLevelNode")
1507                 .id("root-base-layers")
1508                 .root(true)
1509                 .title("Base layers")
1510                 .childrenIds(List.of(
1511                     "lyr:openbasiskaart:osm",
1512                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1513                     "lyr:openbasiskaart-proxied:osm")))
1514             .addBaseLayerNodesItem(new AppTreeLayerNode()
1515                 .objectType("AppTreeLayerNode")
1516                 .id("lyr:openbasiskaart-proxied:osm")
1517                 .serviceId("openbasiskaart-proxied")
1518                 .layerName("osm")
1519                 .visible(false))
1520             .addLayerNodesItem(new AppTreeLevelNode()
1521                 .objectType("AppTreeLevelNode")
1522                 .id("root")
1523                 .root(true)
1524                 .title("Layers")
1525                 .childrenIds(List.of(
1526                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1527                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1528                     "lvl:proxied")))
1529             .addLayerNodesItem(new AppTreeLayerNode()
1530                 .objectType("AppTreeLayerNode")
1531                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1532                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1533                 .layerName("Gemeentegebied")
1534                 .visible(true))
1535             .addLayerNodesItem(new AppTreeLayerNode()
1536                 .objectType("AppTreeLayerNode")
1537                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1538                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1539                 .layerName("Provinciegebied")
1540                 .visible(false))
1541             .addLayerNodesItem(new AppTreeLevelNode()
1542                 .objectType("AppTreeLevelNode")
1543                 .id("lvl:proxied")
1544                 .title("Proxied")
1545                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1546             .addLayerNodesItem(new AppTreeLayerNode()
1547                 .objectType("AppTreeLayerNode")
1548                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1549                 .serviceId("snapshot-geoserver-proxied")
1550                 .layerName("postgis:begroeidterreindeel")
1551                 .visible(false)))
1552         .setSettings(
1553             new AppSettings()
1554                 .putLayerSettingsItem(
1555                     "lyr:openbasiskaart-proxied:osm",
1556                     new AppLayerSettings().title("Openbasiskaart (proxied)"))
1557                 .putLayerSettingsItem(
1558                     "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1559                     new AppLayerSettings()
1560                         .description("This layer should render using purple polygons")
1561                         .selectedStyles(
1562                             List.of(
1563                                 new WMSStyle()
1564                                     .name("purple_polygon")
1565                                     .title("purple_polygon")
1566                                     .abstractText(null)
1567                                     .legendUrl(
1568                                         URI.create(
1569                                             "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"))))));
1570 
1571     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1572     applicationRepository.save(app);
1573 
1574     app = new Application()
1575         .setName("secured-auth")
1576         .setTitle("secured (with authorizations)")
1577         .setCrs("EPSG:28992")
1578         .setAuthorizationRules(List.of(
1579             new AuthorizationRule()
1580                 .groupName("test-foo")
1581                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
1582             new AuthorizationRule()
1583                 .groupName("test-bar")
1584                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
1585         .setContentRoot(new AppContent()
1586             .addLayerNodesItem(new AppTreeLevelNode()
1587                 .objectType("AppTreeLevelNode")
1588                 .id("root")
1589                 .root(true)
1590                 .title("Layers")
1591                 .childrenIds(List.of("lvl:needs-auth", "lvl:public")))
1592             .addLayerNodesItem(new AppTreeLevelNode()
1593                 .objectType("AppTreeLevelNode")
1594                 .id("lvl:public")
1595                 .title("Public")
1596                 .childrenIds(List.of("lyr:snapshot-geoserver:BGT")))
1597             .addLayerNodesItem(new AppTreeLevelNode()
1598                 .objectType("AppTreeLevelNode")
1599                 .id("lvl:needs-auth")
1600                 .title("Needs auth")
1601                 .childrenIds(List.of(
1602                     "lyr:filtered-snapshot-geoserver:BGT",
1603                     "lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1604             .addLayerNodesItem(new AppTreeLayerNode()
1605                 .objectType("AppTreeLayerNode")
1606                 .id("lyr:filtered-snapshot-geoserver:BGT")
1607                 .serviceId("filtered-snapshot-geoserver")
1608                 .layerName("BGT")
1609                 .visible(true))
1610             .addLayerNodesItem(new AppTreeLayerNode()
1611                 .objectType("AppTreeLayerNode")
1612                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1613                 .serviceId("filtered-snapshot-geoserver")
1614                 .layerName("postgis:begroeidterreindeel")
1615                 .visible(true))
1616             .addLayerNodesItem(new AppTreeLayerNode()
1617                 .objectType("AppTreeLayerNode")
1618                 .id("lyr:snapshot-geoserver:BGT")
1619                 .serviceId("snapshot-geoserver")
1620                 .layerName("BGT")
1621                 .visible(true)));
1622 
1623     applicationRepository.save(app);
1624 
1625     app = new Application()
1626         .setName("austria")
1627         .setCrs("EPSG:3857")
1628         .setAuthorizationRules(ruleAnonymousRead)
1629         .setTitle("Austria")
1630         .setInitialExtent(
1631             new Bounds().minx(987982d).miny(5799551d).maxx(1963423d).maxy(6320708d))
1632         .setMaxExtent(
1633             new Bounds().minx(206516d).miny(5095461d).maxx(3146930d).maxy(7096232d))
1634         .setContentRoot(new AppContent()
1635             .addBaseLayerNodesItem(new AppTreeLevelNode()
1636                 .objectType("AppTreeLevelNode")
1637                 .id("root-base-layers")
1638                 .root(true)
1639                 .title("Base layers")
1640                 .childrenIds(List.of(
1641                     "lyr:at-basemap:geolandbasemap",
1642                     "lyr:at-basemap:orthofoto",
1643                     "lvl:orthofoto-labels",
1644                     "lyr:osm:xyz")))
1645             .addBaseLayerNodesItem(new AppTreeLayerNode()
1646                 .objectType("AppTreeLayerNode")
1647                 .id("lyr:at-basemap:geolandbasemap")
1648                 .serviceId("at-basemap")
1649                 .layerName("geolandbasemap")
1650                 .visible(true))
1651             .addBaseLayerNodesItem(new AppTreeLayerNode()
1652                 .objectType("AppTreeLayerNode")
1653                 .id("lyr:at-basemap:orthofoto")
1654                 .serviceId("at-basemap")
1655                 .layerName("bmaporthofoto30cm")
1656                 .visible(false))
1657             .addBaseLayerNodesItem(new AppTreeLevelNode()
1658                 .objectType("AppTreeLevelNode")
1659                 .id("lvl:orthofoto-labels")
1660                 .title("Orthophoto with labels")
1661                 .childrenIds(List.of("lyr:at-basemap:bmapoverlay", "lyr:at-basemap:orthofoto_2")))
1662             .addBaseLayerNodesItem(new AppTreeLayerNode()
1663                 .objectType("AppTreeLayerNode")
1664                 .id("lyr:at-basemap:bmapoverlay")
1665                 .serviceId("at-basemap")
1666                 .layerName("bmapoverlay")
1667                 .visible(false))
1668             .addBaseLayerNodesItem(new AppTreeLayerNode()
1669                 .objectType("AppTreeLayerNode")
1670                 .id("lyr:at-basemap:orthofoto_2")
1671                 .serviceId("at-basemap")
1672                 .layerName("bmaporthofoto30cm")
1673                 .visible(false))
1674             .addBaseLayerNodesItem(new AppTreeLayerNode()
1675                 .objectType("AppTreeLayerNode")
1676                 .id("lyr:osm:xyz")
1677                 .serviceId("osm")
1678                 .layerName("xyz")
1679                 .visible(false)));
1680 
1681     applicationRepository.save(app);
1682 
1683     app = new Application()
1684         .setName("3d_utrecht")
1685         .setCrs("EPSG:3857")
1686         .setAuthorizationRules(ruleLoggedIn)
1687         .setTitle("3D Utrecht")
1688         .setInitialExtent(
1689             new Bounds().minx(558390d).miny(6818485d).maxx(566751d).maxy(6824036d))
1690         .setMaxExtent(
1691             new Bounds().minx(91467d).miny(6496479d).maxx(1037043d).maxy(7147453d))
1692         .setSettings(new AppSettings().uiSettings(new AppUiSettings().enable3D(true)))
1693         .setContentRoot(new AppContent()
1694             .addBaseLayerNodesItem(new AppTreeLevelNode()
1695                 .objectType("AppTreeLevelNode")
1696                 .id("root-base-layers")
1697                 .root(true)
1698                 .title("Base layers")
1699                 .childrenIds(List.of("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", "lyr:osm:xyz")))
1700             .addBaseLayerNodesItem(new AppTreeLayerNode()
1701                 .objectType("AppTreeLayerNode")
1702                 .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1703                 .serviceId("pdok-hwh-luchtfotorgb")
1704                 .layerName("Actueel_orthoHR")
1705                 .visible(true))
1706             .addBaseLayerNodesItem(new AppTreeLayerNode()
1707                 .objectType("AppTreeLayerNode")
1708                 .id("lyr:osm:xyz")
1709                 .serviceId("osm")
1710                 .layerName("xyz")
1711                 .visible(false))
1712             .addLayerNodesItem(new AppTreeLevelNode()
1713                 .objectType("AppTreeLevelNode")
1714                 .id("root")
1715                 .root(true)
1716                 .title("Layers")
1717                 .childrenIds(List.of(
1718                     "lyr:3dbag_utrecht:tiles3d",
1719                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1720                     "lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d",
1721                     "lyr:3d_utrecht_proxied_auth:tiles3d")))
1722             .addLayerNodesItem(new AppTreeLayerNode()
1723                 .objectType("AppTreeLayerNode")
1724                 .id("lyr:3dbag_utrecht:tiles3d")
1725                 .serviceId("3dbag_utrecht")
1726                 .layerName("tiles3d")
1727                 .visible(true))
1728             .addLayerNodesItem(new AppTreeLayerNode()
1729                 .objectType("AppTreeLayerNode")
1730                 .id("lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d")
1731                 .serviceId("3d_basisvoorziening_gebouwen_proxy")
1732                 .layerName("tiles3d")
1733                 .visible(false))
1734             .addLayerNodesItem(new AppTreeLayerNode()
1735                 .objectType("AppTreeLayerNode")
1736                 .id("lyr:3d_utrecht_proxied_auth:tiles3d")
1737                 .serviceId("3d_utrecht_proxied_auth")
1738                 .layerName("tiles3d")
1739                 .visible(false))
1740             .addLayerNodesItem(new AppTreeLayerNode()
1741                 .objectType("AppTreeLayerNode")
1742                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1743                 .serviceId("snapshot-geoserver")
1744                 .layerName("postgis:begroeidterreindeel")
1745                 .visible(true))
1746             .addTerrainLayerNodesItem(new AppTreeLevelNode()
1747                 .objectType("AppTreeLevelNode")
1748                 .id("root-terrain-layers")
1749                 .root(true)
1750                 .title("Terrain Layers")
1751                 .childrenIds(List.of("lyr:ahn_terrain_model:quantizedmesh")))
1752             .addTerrainLayerNodesItem(new AppTreeLayerNode()
1753                 .objectType("AppTreeLayerNode")
1754                 .id("lyr:ahn_terrain_model:quantizedmesh")
1755                 .serviceId("ahn_terrain_model")
1756                 .layerName("quantizedmesh")
1757                 .visible(false)));
1758 
1759     applicationRepository.save(app);
1760 
1761     app = new Application()
1762         .setName("public-with-auth")
1763         .setTitle("Public app with one restricted layer in a group")
1764         .setCrs("EPSG:28992")
1765         .setAuthorizationRules(ruleAnonymousRead)
1766         .setInitialExtent(
1767             new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d))
1768         .setMaxExtent(
1769             new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d))
1770         .setStyling(new AppStyling().logo(logo.getId().toString()))
1771         .setContentRoot(new AppContent()
1772             .addBaseLayerNodesItem(new AppTreeLevelNode()
1773                 .objectType("AppTreeLevelNode")
1774                 .id("root")
1775                 .root(true)
1776                 .title("Basemaps")
1777                 .childrenIds(List.of("lyr:openbasiskaart:osm")))
1778             .addBaseLayerNodesItem(new AppTreeLayerNode()
1779                 .objectType("AppTreeLayerNode")
1780                 .id("lyr:openbasiskaart:osm")
1781                 .serviceId("openbasiskaart")
1782                 .layerName("osm")
1783                 .visible(true))
1784             .addLayerNodesItem(new AppTreeLevelNode()
1785                 .objectType("AppTreeLevelNode")
1786                 .id("root")
1787                 .root(true)
1788                 .title("Application layers")
1789                 .childrenIds(List.of(
1790                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel", "xpfhl34VmghkU12nP9Jer")))
1791             .addLayerNodesItem(new AppTreeLayerNode()
1792                 .objectType("AppTreeLayerNode")
1793                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1794                 .serviceId("snapshot-geoserver")
1795                 .layerName("postgis:kadastraal_perceel")
1796                 .visible(true))
1797             .addLayerNodesItem(new AppTreeLevelNode()
1798                 .id("xpfhl34VmghkU12nP9Jer")
1799                 .root(false)
1800                 .title("restricted")
1801                 .objectType("AppTreeLevelNode")
1802                 .childrenIds(List.of("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1803             .addLayerNodesItem(new AppTreeLayerNode()
1804                 .objectType("AppTreeLayerNode")
1805                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1806                 .visible(true)
1807                 .serviceId("filtered-snapshot-geoserver")
1808                 .layerName("postgis:begroeidterreindeel")))
1809         .setSettings(new AppSettings());
1810 
1811     applicationRepository.save(app);
1812 
1813     Configuration config = new Configuration();
1814     config.setKey(Configuration.DEFAULT_APP);
1815     config.setValue("default");
1816     configurationRepository.save(config);
1817     config = new Configuration();
1818     config.setKey(Configuration.DEFAULT_BASE_APP);
1819     config.setValue("base");
1820     configurationRepository.save(config);
1821   }
1822 
1823   private void createConfigurationTestData() throws JacksonException {
1824     Configuration config = new Configuration();
1825     config.setKey("test");
1826     config.setAvailableForViewer(true);
1827     config.setValue("test value");
1828     config.setJsonValue(new JsonMapper().readTree("{ \"someProperty\": 1, \"nestedObject\": { \"num\": 42 } }"));
1829     configurationRepository.save(config);
1830   }
1831 
1832   @Transactional
1833   public void createSolrIndex() throws Exception {
1834     if (connectToSpatialDbs) {
1835       // flush() the repo because we need to make sure feature type testdata is fully stored
1836       // before creating the Solr index (which requires access to the feature type settings)
1837       featureSourceRepository.flush();
1838 
1839       logger.info("Creating Solr index");
1840       @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
1841       final String solrUrl = "http://" + (connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "solr") + ":8983/solr/";
1842       this.solrService.setSolrUrl(solrUrl);
1843       SolrHelper solrHelper = new SolrHelper(this.solrService.getSolrClientForIndexing())
1844           .withBatchSize(solrBatchSize)
1845           .withGeometryValidationRule(solrGeometryValidationRule);
1846       GeoService geoService =
1847           geoServiceRepository.findById("snapshot-geoserver").orElseThrow();
1848       Application defaultApp = applicationRepository.findByName("default");
1849 
1850       TMFeatureType begroeidterreindeelFT = geoService.findFeatureTypeForLayer(
1851           geoService.findLayer("postgis:begroeidterreindeel"), featureSourceRepository);
1852 
1853       TMFeatureType wegdeelFT = geoService.findFeatureTypeForLayer(
1854           geoService.findLayer("sqlserver:wegdeel"), featureSourceRepository);
1855 
1856       TMFeatureType kadastraalPerceelFT = geoService.findFeatureTypeForLayer(
1857           geoService.findLayer("postgis:kadastraal_perceel"), featureSourceRepository);
1858 
1859       try (solrHelper) {
1860         SearchIndex begroeidterreindeelIndex = null;
1861         if (begroeidterreindeelFT != null) {
1862           begroeidterreindeelIndex = new SearchIndex()
1863               .setName("Begroeidterreindeel")
1864               .setFeatureTypeId(begroeidterreindeelFT.getId())
1865               .setSearchFieldsUsed(List.of("class", "plus_fysiekvoorkomen", "bronhouder"))
1866               .setSearchDisplayFieldsUsed(List.of("class", "plus_fysiekvoorkomen"));
1867           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1868           begroeidterreindeelIndex = solrHelper.addFeatureTypeIndex(
1869               begroeidterreindeelIndex,
1870               begroeidterreindeelFT,
1871               featureSourceFactoryHelper,
1872               searchIndexRepository);
1873           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1874         }
1875 
1876         SearchIndex kadastraalPerceelIndex = null;
1877         if (kadastraalPerceelFT != null) {
1878           kadastraalPerceelIndex = new SearchIndex()
1879               .setName("kadastraal_perceel")
1880               .setFeatureTypeId(kadastraalPerceelFT.getId())
1881               .setSearchFieldsUsed(List.of("aanduiding"))
1882               .setSearchDisplayFieldsUsed(List.of("aanduiding"));
1883           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1884           kadastraalPerceelIndex = solrHelper.addFeatureTypeIndex(
1885               kadastraalPerceelIndex,
1886               kadastraalPerceelFT,
1887               featureSourceFactoryHelper,
1888               searchIndexRepository);
1889           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1890         }
1891 
1892         SearchIndex wegdeelIndex = null;
1893         if (wegdeelFT != null) {
1894           wegdeelIndex = new SearchIndex()
1895               .setName("Wegdeel")
1896               .setFeatureTypeId(wegdeelFT.getId())
1897               .setSearchFieldsUsed(List.of(
1898                   "function_", "plus_fysiekvoorkomenwegdeel", "surfacematerial", "bronhouder"))
1899               .setSearchDisplayFieldsUsed(List.of("function_", "plus_fysiekvoorkomenwegdeel"));
1900           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1901           wegdeelIndex = solrHelper.addFeatureTypeIndex(
1902               wegdeelIndex, wegdeelFT, featureSourceFactoryHelper, searchIndexRepository);
1903           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1904         }
1905 
1906         featureSourceRepository
1907             .getByTitle("PostGIS")
1908             .flatMap(fs -> fs.getFeatureTypes().stream()
1909                 .filter(ft -> ft.getName().equals("bak"))
1910                 .findFirst())
1911             .ifPresent(ft -> {
1912               SearchIndex bak = new SearchIndex()
1913                   .setName("bak")
1914                   .setFeatureTypeId(ft.getId())
1915                   .setSearchFieldsUsed(List.of("gmlid", "identificatie", "plus_type"))
1916                   .setSearchDisplayFieldsUsed(List.of("gmlid", "plus_type", "bronhouder"));
1917               searchIndexRepository.save(bak);
1918               try {
1919                 bak = solrHelper.addFeatureTypeIndex(
1920                     bak, ft, featureSourceFactoryHelper, searchIndexRepository);
1921                 searchIndexRepository.save(bak);
1922               } catch (IOException | SolrServerException e) {
1923                 throw new RuntimeException(e);
1924               }
1925             });
1926 
1927         // creating a solr index of config this will/should fail because there is no primary key in
1928         // the FT
1929         featureSourceRepository
1930             .getByTitle("PostGIS OSM")
1931             .flatMap(fs -> fs.getFeatureTypes().stream()
1932                 .filter(ft -> ft.getName().equals("osm_roads"))
1933                 .findFirst())
1934             .ifPresent(ft -> {
1935               SearchIndex osm_no_pk = new SearchIndex()
1936                   .setName("osm_no_pk")
1937                   .setFeatureTypeId(ft.getId())
1938                   .setSearchFieldsUsed(List.of("landuse", "osm_id", "natural", "boundary"))
1939                   .setSearchDisplayFieldsUsed(
1940                       List.of("landuse", "osm_id", "natural", "amenity", "boundary"));
1941               searchIndexRepository.save(osm_no_pk);
1942             });
1943 
1944         AppTreeLayerNode begroeidTerreindeelLayerNode = defaultApp
1945             .getAllAppTreeLayerNode()
1946             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1947             .findFirst()
1948             .orElse(null);
1949 
1950         if (begroeidTerreindeelLayerNode != null && begroeidterreindeelIndex != null) {
1951           defaultApp
1952               .getAppLayerSettings(begroeidTerreindeelLayerNode)
1953               .setSearchIndexId(begroeidterreindeelIndex.getId());
1954         }
1955 
1956         AppTreeLayerNode kadastraalPerceelLayerNode = defaultApp
1957             .getAllAppTreeLayerNode()
1958             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1959             .findFirst()
1960             .orElse(null);
1961 
1962         if (kadastraalPerceelLayerNode != null && kadastraalPerceelIndex != null) {
1963           defaultApp
1964               .getAppLayerSettings(kadastraalPerceelLayerNode)
1965               .setSearchIndexId(kadastraalPerceelIndex.getId());
1966         }
1967 
1968         AppTreeLayerNode wegdeel = defaultApp
1969             .getAllAppTreeLayerNode()
1970             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:sqlserver:wegdeel"))
1971             .findFirst()
1972             .orElse(null);
1973 
1974         if (wegdeel != null && wegdeelIndex != null) {
1975           defaultApp.getAppLayerSettings(wegdeel).setSearchIndexId(wegdeelIndex.getId());
1976         }
1977 
1978         applicationRepository.save(defaultApp);
1979       }
1980     }
1981   }
1982 
1983   private void createSearchIndexTasks() {
1984     logger.info("Creating search index tasks");
1985     List.of("Begroeidterreindeel", "kadastraal_perceel")
1986         .forEach(name -> searchIndexRepository.findByName(name).ifPresent(index -> {
1987           index.setSchedule(new TaskSchedule()
1988               /* hour */
1989               .cronExpression("0 0 0/1 1/1 * ? *")
1990               // /* 15 min */
1991               // .cronExpression("0 0/15 * 1/1 * ? *")
1992               .description("Update Solr index \"" + name + "\" every hour"));
1993           try {
1994             final UUID uuid = taskManagerService.createTask(
1995                 IndexTask.class,
1996                 new TMJobDataMap(Map.of(
1997                     Task.TYPE_KEY,
1998                     TaskType.INDEX,
1999                     Task.DESCRIPTION_KEY,
2000                     index.getSchedule().getDescription(),
2001                     IndexTask.INDEX_KEY,
2002                     index.getId().toString(),
2003                     Task.PRIORITY_KEY,
2004                     10)),
2005                 index.getSchedule().getCronExpression());
2006 
2007             index.getSchedule().setUuid(uuid);
2008             searchIndexRepository.save(index);
2009 
2010             logger.info("Created task to update Solr index with key: {}", uuid);
2011           } catch (SchedulerException e) {
2012             logger.error("Error creating scheduled solr index task", e);
2013           }
2014         }));
2015   }
2016 
2017   private void createPages() throws IOException {
2018     Upload logo = new Upload()
2019         .setCategory(Upload.CATEGORY_PORTAL_IMAGE)
2020         .setFilename("gradient.svg")
2021         .setMimeType("image/svg+xml")
2022         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
2023         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2024     uploadRepository.save(logo);
2025 
2026     Page loggedIn = new Page();
2027     loggedIn.setAuthorizationRules(ruleLoggedIn);
2028     loggedIn.setName("loggedIn");
2029     loggedIn.setType("page");
2030     loggedIn.setContent("About Tailormap");
2031     loggedIn.setContent("""
2032 # About Tailormap
2033 This is a page for logged in users.
2034 """);
2035     pageRepository.save(loggedIn);
2036 
2037     Page about = new Page();
2038     about.setAuthorizationRules(ruleAnonymousRead);
2039     about.setName("about");
2040     about.setType("page");
2041     about.setContent("About Tailormap");
2042     about.setContent("""
2043 # About Tailormap
2044 
2045 This is a page about *Tailormap*. It doesn't say much yet.
2046 """);
2047     pageRepository.save(about);
2048 
2049     Page page = new Page();
2050     page.setAuthorizationRules(ruleAnonymousRead);
2051     page.setName("home");
2052     page.setType("page");
2053     page.setTitle("Tailormap - Home");
2054     page.setContent("""
2055 # Welcome to Tailormap!
2056 
2057 This page is only visible when you implement a frontend to display pages, or get it (including a simple CMS)
2058 from [B3Partners](https://www.b3partners.nl)!
2059 """);
2060     page.setClassName(null);
2061     page.setTiles(List.of(
2062         new PageTile()
2063             .authorizationRules(ruleAnonymousRead)
2064             .id(UUID.randomUUID().toString())
2065             .title("Default app")
2066             .tileType(PageTile.TileTypeEnum.APPLICATION)
2067             .applicationId(Optional.ofNullable(applicationRepository.findByName("default"))
2068                 .map(Application::getId)
2069                 .orElse(null))
2070             .image(logo.getId().toString())
2071             .content("*Default app* tile content")
2072             .filterRequireAuthorization(false)
2073             .openInNewWindow(false),
2074         new PageTile()
2075             .authorizationRules(ruleAnonymousRead)
2076             .id(UUID.randomUUID().toString())
2077             .title("Secured app")
2078             .tileType(PageTile.TileTypeEnum.APPLICATION)
2079             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
2080                 .map(Application::getId)
2081                 .orElse(null))
2082             .filterRequireAuthorization(true)
2083             .content("Secure app, only shown if user has authorization")
2084             .openInNewWindow(false),
2085         new PageTile()
2086             .authorizationRules(ruleAnonymousRead)
2087             .id(UUID.randomUUID().toString())
2088             .title("Secured app (unfiltered)")
2089             .tileType(PageTile.TileTypeEnum.APPLICATION)
2090             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
2091                 .map(Application::getId)
2092                 .orElse(null))
2093             .filterRequireAuthorization(false)
2094             .content("Secure app, tile shown to everyone")
2095             .openInNewWindow(false),
2096         new PageTile()
2097             .authorizationRules(ruleAnonymousRead)
2098             .id(UUID.randomUUID().toString())
2099             .title("About")
2100             .tileType(PageTile.TileTypeEnum.PAGE)
2101             .pageId(about.getId())
2102             .openInNewWindow(false),
2103         new PageTile()
2104             .authorizationRules(ruleAnonymousRead)
2105             .id(UUID.randomUUID().toString())
2106             .title("B3Partners")
2107             .tileType(PageTile.TileTypeEnum.URL)
2108             .url("https://www.b3partners.nl/")
2109             .openInNewWindow(true),
2110         new PageTile()
2111             .authorizationRules(ruleLoggedIn)
2112             .id(UUID.randomUUID().toString())
2113             .title("Github repository")
2114             .tileType(PageTile.TileTypeEnum.URL)
2115             .url("https://github.com/Tailormap/tailormap-viewer")
2116             .openInNewWindow(true),
2117         new PageTile()
2118             .authorizationRules(ruleAnonymousRead)
2119             .id(UUID.randomUUID().toString())
2120             .tileType(PageTile.TileTypeEnum.PAGE)
2121             .title("Secured page")
2122             .pageId(loggedIn.getId())
2123             .filterRequireAuthorization(true)
2124             .openInNewWindow(true),
2125         new PageTile()
2126             .authorizationRules(ruleAnonymousRead)
2127             .id(UUID.randomUUID().toString())
2128             .tileType(PageTile.TileTypeEnum.PAGE)
2129             .title("Secured page (unfiltered)")
2130             .pageId(loggedIn.getId())
2131             .filterRequireAuthorization(false)
2132             .openInNewWindow(true)));
2133     pageRepository.save(page);
2134 
2135     Configuration c = new Configuration();
2136     c.setKey(HOME_PAGE);
2137     c.setValue(page.getId().toString());
2138     configurationRepository.save(c);
2139 
2140     List<MenuItem> globalMenuItems = List.of(
2141         new MenuItem().pageId(about.getId()).label("About").openInNewWindow(false),
2142         new MenuItem()
2143             .label("B3Partners website")
2144             .url("https://www.b3partners.nl/")
2145             .openInNewWindow(true)
2146             .exclusiveOnPageId(about.getId()));
2147     c = new Configuration();
2148     c.setKey(PORTAL_MENU);
2149     c.setJsonValue(new JsonMapper().valueToTree(globalMenuItems));
2150     configurationRepository.save(c);
2151   }
2152 
2153   private void insertTestDrawing() {
2154     // note that the drawing uuid is hardcoded and used in the DrawingControllerIntegrationTest
2155     try {
2156       this.jdbcClient.sql("""
2157 INSERT INTO data.drawing (id,name,description,domain_data,"access",created_by,created_at,updated_by,updated_at,srid,"version") VALUES
2158 ('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);
2159 """).update();
2160 
2161       this.jdbcClient.sql("""
2162 INSERT INTO data.drawing_feature (drawing_id,id,geometry,properties) VALUES
2163 ('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),
2164 ('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),
2165 ('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)
2166 """).update();
2167     } catch (Exception any) {
2168       logger.error("Error inserting test drawing in data schema, some tests may fail", any);
2169     }
2170   }
2171 
2172   private void insertDrawingStyle() throws IOException {
2173     Upload upload = new Upload()
2174         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
2175         .setMimeType("image/svg+xml")
2176         .setFilename("ISO_7001_PI_PF_007.svg")
2177         .setContent(new ClassPathResource("test/ISO_7001_PI_PF_007.svg").getContentAsByteArray())
2178         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2179     upload = uploadRepository.save(upload);
2180     UUID drinkwaterImageId = upload.getId();
2181 
2182     upload = new Upload()
2183         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
2184         .setMimeType("image/svg+xml")
2185         .setFilename("lichtpunt.svg")
2186         .setContent(new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray())
2187         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2188     upload = uploadRepository.save(upload);
2189     UUID lichtpuntImageId = upload.getId();
2190 
2191     upload = new Upload()
2192         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
2193         .setMimeType("image/svg+xml")
2194         .setFilename("ISO_7010_E003_-_First_aid_sign.svg")
2195         .setContent(new ClassPathResource("test/ISO_7010_E003_-_First_aid_sign.svg").getContentAsByteArray())
2196         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2197     upload = uploadRepository.save(upload);
2198     UUID firstAidImageId = upload.getId();
2199 
2200     Properties props = new Properties();
2201     props.putAll(Map.of(
2202         "water-uuid", drinkwaterImageId.toString(),
2203         "lichtpunt-uuid", lichtpuntImageId.toString(),
2204         "first-aid-uuid", firstAidImageId.toString()));
2205 
2206     String drawingStyles =
2207         new ClassPathResource("test/object-drawing-style.json").getContentAsString(StandardCharsets.UTF_8);
2208     drawingStyles = new PropertyPlaceholderHelper("${", "}").replacePlaceholders(drawingStyles, props);
2209 
2210     // save the updated drawing style
2211     upload = new Upload()
2212         .setCategory(Upload.CATEGORY_DRAWING_STYLE)
2213         .setMimeType("application/json")
2214         .setFilename("object-drawing-style.json")
2215         .setContent(drawingStyles.getBytes(StandardCharsets.UTF_8))
2216         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2217     uploadRepository.save(upload);
2218   }
2219 }