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