Skip to content
Snippets Groups Projects
Commit 6af38eda authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Handled token for private files retrieval. Added several tests

parent 7681799a
No related branches found
No related tags found
No related merge requests found
Pipeline #843 passed
...@@ -2,6 +2,31 @@ ...@@ -2,6 +2,31 @@
## Database ## Database
This VOSpace implementation uses the database populated by the [VOSpace Transfer Service application](https://www.ict.inaf.it/gitlab/ia2/vospace-transfer-service). To avoid duplicating database definitions, DAO test classes load the database directly from the files of that repository. We assume that when running the tests the git repository exists and it is located in the same parent folder containing this repository. We could decide to create a dedicate common repository for sharing only the database structure and configuration files between the 2 projects. This VOSpace implementation uses the database populated by the [VOSpace Transfer Service application](https://www.ict.inaf.it/gitlab/ia2/vospace-transfer-service). The structure of the database is defined in a [separate repository](https://www.ict.inaf.it/gitlab/ia2/vospace-file-catalog). To avoid duplicating database definitions, DAO test classes load the database directly from the files of that repository. We assume that when running the tests the git repository exists and it is located in the same parent folder containing this repository.
To reconfigure the path of that repository edit the property `init_database_scripts_path` in test.properties. To reconfigure the path of that repository edit the property `init_database_scripts_path` in test.properties.
## Loading fake users in MockMvc
Test classes annotated with `@SpringBootTest` and `@AutoConfigureMockMvc` can be used to test REST controllers. Theoretically it should be possible configure a fake principal to each test request using the following notation:
```java
mockMvc.perform(post("/endpoint").principal(myFakeUser));
```
However it seems that the method is ignored if the principal is set using a custom servlet filter, like in our case (see `TokenFilter` registration defined in `VospaceApplication` class).
To bypass the problem a fake `TokenFilter` has been defined in `TokenFilterConfig` test class. This filter returns some fake users based on the received fake token. If you need additional test users just add them in the `getFakeUser()` method.
To use the fake filter add the following annotations to the test class:
```java
@ContextConfiguration(classes = {TokenFilterConfig.class})
@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true")
```
Then add the fake token to the test request:
```java
mockMvc.perform(post("/endpoint").header("Authorization", "Bearer user1_token"));
```
...@@ -76,6 +76,8 @@ public class TransferController { ...@@ -76,6 +76,8 @@ public class TransferController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} }
jobService.setJobPhase(job, phase);
return getJobRedirect(job.getJobId()); return getJobRedirect(job.getJobId());
}).orElse(ResponseEntity.notFound().build()); }).orElse(ResponseEntity.notFound().build());
......
package it.inaf.oats.vospace; package it.inaf.oats.vospace;
import it.inaf.ia2.aa.ServletRapClient;
import it.inaf.ia2.aa.data.User;
import it.inaf.ia2.rap.client.call.TokenExchangeRequest;
import it.inaf.oats.vospace.persistence.NodeDAO; import it.inaf.oats.vospace.persistence.NodeDAO;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import javax.servlet.http.HttpServletRequest;
import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.uws.v1.JobSummary;
import net.ivoa.xml.uws.v1.ResultReference; import net.ivoa.xml.uws.v1.ResultReference;
import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Node;
import net.ivoa.xml.vospace.v2.Property;
import net.ivoa.xml.vospace.v2.Protocol; import net.ivoa.xml.vospace.v2.Protocol;
import net.ivoa.xml.vospace.v2.Transfer; import net.ivoa.xml.vospace.v2.Transfer;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
...@@ -24,6 +29,12 @@ public class UriService { ...@@ -24,6 +29,12 @@ public class UriService {
@Autowired @Autowired
private NodeDAO nodeDao; private NodeDAO nodeDao;
@Autowired
private HttpServletRequest servletRequest;
@Autowired
private ServletRapClient rapClient;
public void setTransferJobResult(JobSummary job) { public void setTransferJobResult(JobSummary job) {
List<ResultReference> results = new ArrayList<>(); List<ResultReference> results = new ArrayList<>();
...@@ -39,11 +50,12 @@ public class UriService { ...@@ -39,11 +50,12 @@ public class UriService {
Transfer transfer = getTransfer(job); Transfer transfer = getTransfer(job);
Protocol protocol = new Protocol(); Protocol protocol = transfer.getProtocols().get(0);
protocol.setUri("ivo://ivoa.net/vospace/core#httpget");
protocol.setEndpoint(getEndpoint(job));
transfer.getProtocols().add(protocol); if (!"ivo://ivoa.net/vospace/core#httpget".equals(protocol.getUri())) {
throw new IllegalStateException("Unsupported protocol " + protocol.getUri());
}
protocol.setEndpoint(getEndpoint(job));
} }
private String getEndpoint(JobSummary job) { private String getEndpoint(JobSummary job) {
...@@ -58,7 +70,40 @@ public class UriService { ...@@ -58,7 +70,40 @@ public class UriService {
// TODO build the path according to node type // TODO build the path according to node type
// //
// TODO add token for authenticated access // TODO add token for authenticated access
return fileServiceUrl + relativePath + "?jobId=" + job.getJobId(); String endpoint = fileServiceUrl + relativePath + "?jobId=" + job.getJobId();
if (!"true".equals(getProperty(node, "publicread"))) {
endpoint += "&token=" + getEndpointToken(fileServiceUrl + relativePath);
}
return endpoint;
}
private String getEndpointToken(String endpoint) {
String token = ((User) servletRequest.getUserPrincipal()).getAccessToken();
if (token == null) {
// TODO: use PermissionDenied VoSpaceException
throw new IllegalStateException("Token is null");
}
TokenExchangeRequest exchangeRequest = new TokenExchangeRequest()
.setSubjectToken(token)
.setResource(endpoint);
// TODO: add audience and scope
return rapClient.exchangeToken(exchangeRequest, servletRequest);
}
private String getProperty(Node node, String propertyName) {
for (Property property : node.getProperties()) {
if (property.getUri().equals("ivo://ivoa.net/vospace/core#".concat(propertyName))) {
return property.getValue();
}
}
return null;
} }
private Transfer getTransfer(JobSummary job) { private Transfer getTransfer(JobSummary job) {
......
package it.inaf.oats.vospace; package it.inaf.oats.vospace;
import it.inaf.ia2.aa.ServiceLocator;
import it.inaf.ia2.aa.ServletRapClient;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
...@@ -20,4 +22,9 @@ public class VospaceApplication { ...@@ -20,4 +22,9 @@ public class VospaceApplication {
registration.addUrlPatterns("/*"); registration.addUrlPatterns("/*");
return registration; return registration;
} }
@Bean
public ServletRapClient servletRapClient() {
return (ServletRapClient) ServiceLocator.getInstance().getRapClient();
}
} }
package it.inaf.oats.vospace;
import it.inaf.ia2.aa.TokenFilter;
import it.inaf.ia2.aa.data.User;
import java.io.IOException;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class TokenFilterConfig {
@Bean
@Primary
public FilterRegistrationBean tokenFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new FakeTokenFilter());
registration.addUrlPatterns("/*");
return registration;
}
private static class FakeTokenFilter extends TokenFilter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String authHeader = request.getHeader("Authorization");
if (authHeader != null) {
if (authHeader.startsWith("Bearer ")) {
String token = authHeader.substring("Bearer ".length());
HttpServletRequestWrapper requestWithPrincipal = new RequestWithPrincipal(request, getFakeUser(token));
chain.doFilter(requestWithPrincipal, response);
return;
}
}
chain.doFilter(getAnonymousServletRequest(request), response);
}
private User getFakeUser(String token) {
User user = new User();
switch (token) {
case "user1_token":
user.setUserId("user1").setUserLabel("User1");
break;
case "user2_token":
user.setUserId("user2").setUserLabel("User2").setGroups(Arrays.asList("group1", "group2"));
break;
default:
throw new IllegalArgumentException("Fake user not configured for token " + token);
}
user.setAccessToken(token);
return user;
}
private static HttpServletRequestWrapper getAnonymousServletRequest(HttpServletRequest request) {
User anonymousUser = new User()
.setUserId("anonymous")
.setUserLabel("Anonymous")
.setGroups(new ArrayList<>());
return new RequestWithPrincipal(request, anonymousUser);
}
private static class RequestWithPrincipal extends HttpServletRequestWrapper {
private final User user;
public RequestWithPrincipal(HttpServletRequest request, User user) {
super(request);
this.user = user;
}
@Override
public Principal getUserPrincipal() {
return user;
}
}
}
}
package it.inaf.oats.vospace; package it.inaf.oats.vospace;
import it.inaf.ia2.aa.data.User;
import static it.inaf.oats.vospace.VOSpaceXmlTestUtil.loadDocument; import static it.inaf.oats.vospace.VOSpaceXmlTestUtil.loadDocument;
import it.inaf.oats.vospace.persistence.JobDAO; import it.inaf.oats.vospace.persistence.JobDAO;
import it.inaf.oats.vospace.persistence.NodeDAO;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Optional; import java.util.Optional;
import net.ivoa.xml.uws.v1.ExecutionPhase;
import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.uws.v1.JobSummary;
import net.ivoa.xml.vospace.v2.DataNode;
import net.ivoa.xml.vospace.v2.Node;
import net.ivoa.xml.vospace.v2.Property;
import net.ivoa.xml.vospace.v2.Protocol;
import net.ivoa.xml.vospace.v2.Transfer;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.matchesPattern;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
...@@ -15,28 +28,157 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock ...@@ -15,28 +28,157 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.w3c.dom.Document; import org.w3c.dom.Document;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ContextConfiguration(classes = {TokenFilterConfig.class})
@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true")
public class TransferControllerTest { public class TransferControllerTest {
@MockBean @MockBean
private JobDAO dao; private JobDAO jobDao;
@MockBean
private NodeDAO nodeDao;
@MockBean
private TapeService tapeService;
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@Test
public void testPullFromVoSpaceAsync() throws Exception {
Node node = mockPublicDataNode();
when(nodeDao.listNode(eq("/mynode"))).thenReturn(Optional.of(node));
String requestBody = getResourceFileContent("pullFromVoSpace.xml");
String redirect = mockMvc.perform(post("/transfers?PHASE=RUN")
.content(requestBody)
.contentType(MediaType.APPLICATION_XML)
.accept(MediaType.APPLICATION_XML))
.andDo(print())
.andExpect(status().is3xxRedirection())
.andReturn().getResponse().getHeader("Location");
assertThat(redirect, matchesPattern("^/transfers/.*"));
}
@Test
public void testPullFromVoSpaceSync() throws Exception {
Node node = mockPublicDataNode();
when(nodeDao.listNode(eq("/mynode"))).thenReturn(Optional.of(node));
String requestBody = getResourceFileContent("pullFromVoSpace.xml");
String redirect = mockMvc.perform(post("/synctrans")
.content(requestBody)
.contentType(MediaType.APPLICATION_XML)
.accept(MediaType.APPLICATION_XML))
.andDo(print())
.andExpect(status().is3xxRedirection())
.andReturn().getResponse().getHeader("Location");
assertThat(redirect, matchesPattern("^/transfers/.*/results/transferDetails"));
}
@Test
public void testPullToVoSpace() throws Exception {
String requestBody = getResourceFileContent("pullToVoSpace.xml");
String redirect = mockMvc.perform(post("/transfers?PHASE=RUN")
.content(requestBody)
.contentType(MediaType.APPLICATION_XML)
.accept(MediaType.APPLICATION_XML))
.andDo(print())
.andExpect(status().is3xxRedirection())
.andReturn().getResponse().getHeader("Location");
assertThat(redirect, matchesPattern("^/transfers/.*"));
verify(tapeService, times(1)).startJob(any());
}
@Test
public void testSetJobPhase() throws Exception {
Node node = mockPublicDataNode();
when(nodeDao.listNode(eq("/mynode"))).thenReturn(Optional.of(node));
JobSummary job = getFakePendingJob();
when(jobDao.getJob(eq("123"))).thenReturn(Optional.of(job));
User user = new User();
user.setUserId("ownerId");
String redirect = mockMvc.perform(post("/transfers/123/phase")
.header("Authorization", "Bearer user1_token")
.param("PHASE", "RUN")
.accept(MediaType.APPLICATION_XML))
.andDo(print())
.andExpect(status().is3xxRedirection())
.andReturn().getResponse().getHeader("Location");
verify(jobDao, times(1)).updateJob(any());
assertThat(redirect, matchesPattern("^/transfers/.*"));
}
@Test
public void testGetTransferDetails() throws Exception {
JobSummary job = getFakePendingJob();
when(jobDao.getJob(eq("123"))).thenReturn(Optional.of(job));
mockMvc.perform(get("/transfers/123/results/transferDetails")
.header("Authorization", "Bearer user1_token")
.accept(MediaType.APPLICATION_XML))
.andDo(print())
.andExpect(status().isOk());
}
@Test
public void testGetJobPhase() throws Exception {
JobSummary job = getFakePendingJob();
when(jobDao.getJob(eq("123"))).thenReturn(Optional.of(job));
String phase = mockMvc.perform(get("/transfers/123/phase")
.header("Authorization", "Bearer user1_token"))
.andDo(print())
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertEquals("PENDING", phase);
}
private Node mockPublicDataNode() {
Node node = new DataNode();
Property property = new Property();
property.setUri("ivo://ivoa.net/vospace/core#publicread");
property.setValue("true");
node.getProperties().add(property);
return node;
}
@Test @Test
public void testGetJob() throws Exception { public void testGetJob() throws Exception {
JobSummary job = new JobSummary(); JobSummary job = new JobSummary();
when(dao.getJob(eq("123"))).thenReturn(Optional.of(job)); when(jobDao.getJob(eq("123"))).thenReturn(Optional.of(job));
String xml = mockMvc.perform(get("/transfers/123") String xml = mockMvc.perform(get("/transfers/123")
.accept(MediaType.APPLICATION_XML)) .accept(MediaType.APPLICATION_XML))
...@@ -47,6 +189,31 @@ public class TransferControllerTest { ...@@ -47,6 +189,31 @@ public class TransferControllerTest {
Document doc = loadDocument(xml); Document doc = loadDocument(xml);
assertEquals("uws:job", doc.getDocumentElement().getNodeName()); assertEquals("uws:job", doc.getDocumentElement().getNodeName());
verify(dao, times(1)).getJob(eq("123")); verify(jobDao, times(1)).getJob(eq("123"));
}
private JobSummary getFakePendingJob() {
JobSummary job = new JobSummary();
job.setPhase(ExecutionPhase.PENDING);
job.setOwnerId("user1");
Transfer transfer = new Transfer();
transfer.setDirection("pullFromVoSpace");
transfer.setTarget("vos://example.com!vospace/mynode");
Protocol protocol = new Protocol();
protocol.setUri("ivo://ivoa.net/vospace/core#httpget");
transfer.getProtocols().add(protocol);
JobSummary.JobInfo jobInfo = new JobSummary.JobInfo();
jobInfo.getAny().add(transfer);
job.setJobInfo(jobInfo);
return job;
}
protected static String getResourceFileContent(String fileName) throws Exception {
try ( InputStream in = TransferControllerTest.class.getClassLoader().getResourceAsStream(fileName)) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
} }
} }
package it.inaf.oats.vospace; package it.inaf.oats.vospace;
import it.inaf.ia2.aa.ServletRapClient;
import it.inaf.ia2.aa.data.User;
import it.inaf.oats.vospace.persistence.NodeDAO; import it.inaf.oats.vospace.persistence.NodeDAO;
import java.util.Optional; import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.uws.v1.JobSummary;
import net.ivoa.xml.vospace.v2.DataNode; import net.ivoa.xml.vospace.v2.DataNode;
import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Node;
import net.ivoa.xml.vospace.v2.Property;
import net.ivoa.xml.vospace.v2.Transfer; import net.ivoa.xml.vospace.v2.Transfer;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
@SpringBootTest @SpringBootTest
...@@ -24,16 +34,71 @@ public class UriServiceTest { ...@@ -24,16 +34,71 @@ public class UriServiceTest {
@MockBean @MockBean
private NodeDAO dao; private NodeDAO dao;
@MockBean
private ServletRapClient rapClient;
@Autowired
private HttpServletRequest servletRequest;
@Autowired @Autowired
private UriService transferService; private UriService uriService;
@TestConfiguration
public static class TestConfig {
/**
* Necessary because MockBean doesn't work with HttpServletRequest.
*/
@Bean
@Primary
public HttpServletRequest servletRequest() {
return mock(HttpServletRequest.class);
}
}
@Test @Test
public void testSimpleUrl() { public void testPublicUrl() {
Node node = new DataNode(); Node node = new DataNode();
Property property = new Property();
property.setUri("ivo://ivoa.net/vospace/core#publicread");
property.setValue("true");
node.getProperties().add(property);
when(dao.listNode(eq("/mydata1"))).thenReturn(Optional.of(node)); when(dao.listNode(eq("/mydata1"))).thenReturn(Optional.of(node));
JobSummary job = getJob();
uriService.setTransferJobResult(job);
assertEquals("http://file-service/mydata1?jobId=job-id", job.getResults().get(0).getHref());
}
@Test
public void testPrivateUrl() {
Node node = new DataNode();
when(dao.listNode(eq("/mydata1"))).thenReturn(Optional.of(node));
User user = mock(User.class);
when(user.getAccessToken()).thenReturn("<token>");
when(servletRequest.getUserPrincipal()).thenReturn(user);
when(rapClient.exchangeToken(argThat(req -> {
assertEquals("<token>", req.getSubjectToken());
assertEquals("http://file-service/mydata1", req.getResource());
return true;
}), any())).thenReturn("<new-token>");
JobSummary job = getJob();
uriService.setTransferJobResult(job);
assertEquals("http://file-service/mydata1?jobId=job-id&token=<new-token>", job.getResults().get(0).getHref());
}
private JobSummary getJob() {
Transfer transfer = new Transfer(); Transfer transfer = new Transfer();
transfer.setTarget("vos://example.com!vospace/mydata1"); transfer.setTarget("vos://example.com!vospace/mydata1");
...@@ -45,8 +110,6 @@ public class UriServiceTest { ...@@ -45,8 +110,6 @@ public class UriServiceTest {
job.setJobInfo(jobInfo); job.setJobInfo(jobInfo);
transferService.setTransferJobResult(job); return job;
assertEquals("http://file-service/mydata1?jobId=job-id", job.getResults().get(0).getHref());
} }
} }
<vos:transfer xmlns:vos="http://www.ivoa.net/xml/VOSpace/v2.0" version="2.1">
<vos:target>vos://example.com!vospace/mynode</vos:target>
<vos:direction>pullFromVoSpace</vos:direction>
<vos:protocol uri="ivo://ivoa.net/vospace/core#httpget" />
</vos:transfer>
\ No newline at end of file
<vos:transfer xmlns:vos="http://www.ivoa.net/xml/VOSpace/v2.0" version="2.1">
<vos:target>vos://example.com!vospace/mynode</vos:target>
<vos:direction>pullToVoSpace</vos:direction>
<vos:protocol uri="ivo://ivoa.net/vospace/core#httpget" />
</vos:transfer>
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment