Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9345848
vault.spec.js
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
18 KB
Subscribers
None
vault.spec.js
View Options
/**
* Copyright (C) 2019-2022 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
const
progressbarColors
=
{
'new'
:
'rgba(128, 128, 128, 0.5)'
,
'pending'
:
'rgba(0, 0, 255, 0.5)'
,
'done'
:
'rgb(92, 184, 92)'
};
function
checkVaultCookingTask
(
objectType
)
{
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
cy
.
contains
(
'.dropdown-item'
,
objectType
)
.
click
();
cy
.
wait
(
'@checkVaultCookingTask'
);
}
function
getVaultItemList
()
{
return
JSON
.
parse
(
window
.
localStorage
.
getItem
(
'swh-vault-cooking-tasks'
));
}
function
updateVaultItemList
(
vaultItems
)
{
window
.
localStorage
.
setItem
(
'swh-vault-cooking-tasks'
,
JSON
.
stringify
(
vaultItems
));
}
// Mocks API response : /api/1/vault/(:bundleType)/(:swhid)
// bundleType : {'flat', 'git_bare'}
function
genVaultCookingResponse
(
bundleType
,
swhid
,
status
,
message
,
fetchUrl
)
{
return
{
'bundle_type'
:
bundleType
,
'id'
:
1
,
'progress_message'
:
message
,
'status'
:
status
,
'swhid'
:
swhid
,
'fetch_url'
:
fetchUrl
};
};
// Tests progressbar color, status
// And status in localStorage
function
testStatus
(
taskId
,
color
,
statusMsg
,
status
)
{
cy
.
get
(
`.swh-vault-table #vault-task-
${
CSS
.
escape
(
taskId
)
}
`
)
.
should
(
'be.visible'
)
.
find
(
'.progress-bar'
)
.
should
(
'be.visible'
)
.
and
(
'have.css'
,
'background-color'
,
color
)
.
and
(
'contain'
,
statusMsg
)
.
then
(()
=>
{
// Vault item with object_id as taskId should exist in localStorage
const
currentVaultItems
=
getVaultItemList
();
const
vaultItem
=
currentVaultItems
.
find
(
obj
=>
obj
.
swhid
===
taskId
);
assert
.
isNotNull
(
vaultItem
);
assert
.
strictEqual
(
vaultItem
.
status
,
status
);
});
}
describe
(
'Vault Cooking User Interface Tests'
,
function
()
{
before
(
function
()
{
const
dirInfo
=
this
.
origin
[
0
].
directory
[
0
];
this
.
directory
=
`swh:1:dir:
${
dirInfo
.
id
}
`
;
this
.
directoryUrl
=
this
.
Urls
.
browse_origin_directory
()
+
`?origin_url=
${
this
.
origin
[
0
].
url
}
&path=
${
dirInfo
.
path
}
`
;
this
.
vaultDirectoryUrl
=
this
.
Urls
.
api_1_vault_cook_flat
(
this
.
directory
);
this
.
vaultFetchDirectoryUrl
=
this
.
Urls
.
api_1_vault_fetch_flat
(
this
.
directory
);
this
.
revisionId
=
this
.
origin
[
1
].
revisions
[
0
];
this
.
revision
=
`swh:1:rev:
${
this
.
revisionId
}
`
;
this
.
revisionUrl
=
this
.
Urls
.
browse_revision
(
this
.
revisionId
);
this
.
vaultRevisionUrl
=
this
.
Urls
.
api_1_vault_cook_git_bare
(
this
.
revision
);
this
.
vaultFetchRevisionUrl
=
this
.
Urls
.
api_1_vault_fetch_git_bare
(
this
.
revision
);
const
release
=
this
.
origin
[
1
].
release
;
this
.
releaseUrl
=
this
.
Urls
.
browse_release
(
release
.
id
)
+
`?origin_url=
${
this
.
origin
[
1
].
url
}
`
;
this
.
vaultReleaseDirectoryUrl
=
this
.
Urls
.
api_1_vault_cook_flat
(
`swh:1:dir:
${
release
.
directory
}
`
);
});
beforeEach
(
function
()
{
// For some reason, this gets reset if we define it in the before() hook,
// so we need to define it here
this
.
vaultItems
=
[
{
'bundle_type'
:
'git_bare'
,
'swhid'
:
this
.
revision
,
'email'
:
''
,
'status'
:
'done'
,
'fetch_url'
:
`/api/1/vault/git-bare/
${
this
.
revision
}
/raw/`
,
'progress_message'
:
null
}
];
this
.
legacyVaultItems
=
[
{
'object_type'
:
'revision'
,
'object_id'
:
this
.
revisionId
,
'email'
:
''
,
'status'
:
'done'
,
'fetch_url'
:
`/api/1/vault/revision/
${
this
.
revisionId
}
/gitfast/raw/`
,
'progress_message'
:
null
}
];
this
.
genVaultDirCookingResponse
=
(
status
,
message
=
null
)
=>
{
return
genVaultCookingResponse
(
'flat'
,
this
.
directory
,
status
,
message
,
this
.
vaultFetchDirectoryUrl
);
};
this
.
genVaultRevCookingResponse
=
(
status
,
message
=
null
)
=>
{
return
genVaultCookingResponse
(
'git_bare'
,
this
.
revision
,
status
,
message
,
this
.
vaultFetchRevisionUrl
);
};
});
it
(
'should report an error when vault service is experiencing issues'
,
function
()
{
// Browse a directory
cy
.
visit
(
this
.
directoryUrl
);
// Stub responses when requesting the vault API to simulate
// an internal server error
cy
.
intercept
(
this
.
vaultDirectoryUrl
,
{
body
:
{
'exception'
:
'APIError'
},
statusCode
:
500
}).
as
(
'checkVaultCookingTask'
);
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
// Check error alert is displayed
cy
.
get
(
'.alert-danger'
)
.
should
(
'be.visible'
)
.
should
(
'contain'
,
'Archive cooking service is currently experiencing issues.'
);
});
it
(
'should report an error when a cooking task creation failed'
,
function
()
{
// Browse a directory
cy
.
visit
(
this
.
directoryUrl
);
// Stub responses when requesting the vault API to simulate
// a task can not be created
cy
.
intercept
(
'GET'
,
this
.
vaultDirectoryUrl
,
{
body
:
{
'exception'
:
'NotFoundExc'
}
}).
as
(
'checkVaultCookingTask'
);
cy
.
intercept
(
'POST'
,
this
.
vaultDirectoryUrl
,
{
body
:
{
'exception'
:
'ValueError'
},
statusCode
:
500
}).
as
(
'createVaultCookingTask'
);
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
// Create a vault cooking task through the GUI
cy
.
get
(
'.modal-dialog'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@createVaultCookingTask'
);
// Check error alert is displayed
cy
.
get
(
'.alert-danger'
)
.
should
(
'be.visible'
)
.
should
(
'contain'
,
'Archive cooking request submission failed.'
);
});
it
(
'should display previous cooking tasks'
,
function
()
{
updateVaultItemList
(
this
.
vaultItems
);
cy
.
visit
(
this
.
Urls
.
browse_vault
());
cy
.
contains
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
button`
,
'Download'
)
.
click
();
});
it
(
'should display and upgrade previous cooking tasks from the legacy format'
,
function
()
{
updateVaultItemList
(
this
.
legacyVaultItems
);
cy
.
visit
(
this
.
Urls
.
browse_vault
());
// Check it is displayed
cy
.
contains
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
button`
,
'Download'
)
.
then
(()
=>
{
// Check the LocalStorage was upgraded
expect
(
getVaultItemList
()).
to
.
deep
.
equal
(
this
.
vaultItems
);
});
});
it
(
'should create a directory cooking task and report the success'
,
function
()
{
// Browse a directory
cy
.
visit
(
this
.
directoryUrl
);
// Stub response to the vault API to simulate archive download
cy
.
intercept
(
'GET'
,
this
.
vaultFetchDirectoryUrl
,
{
fixture
:
`
${
this
.
directory
.
replace
(
/:/g
,
'_'
)
}
.tar.gz`
,
headers
:
{
'Content-disposition'
:
`attachment; filename=
${
this
.
directory
.
replace
(
/:/g
,
'_'
)
}
.tar.gz`
,
'Content-Type'
:
'application/gzip'
}
}).
as
(
'fetchCookedArchive'
);
// Stub responses when checking vault task status
const
checkVaulResponses
=
[
{
'exception'
:
'NotFoundExc'
},
this
.
genVaultDirCookingResponse
(
'new'
),
this
.
genVaultDirCookingResponse
(
'pending'
,
'Processing...'
),
this
.
genVaultDirCookingResponse
(
'done'
)
];
// trick to override the response of an intercepted request
// https://github.com/cypress-io/cypress/issues/9302
cy
.
intercept
(
'GET'
,
this
.
vaultDirectoryUrl
,
req
=>
req
.
reply
(
checkVaulResponses
.
shift
()))
.
as
(
'checkVaultCookingTask'
);
// Stub responses when requesting the vault API to simulate
// a task has been created
cy
.
intercept
(
'POST'
,
this
.
vaultDirectoryUrl
,
{
body
:
this
.
genVaultDirCookingResponse
(
'new'
)
}).
as
(
'createVaultCookingTask'
);
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
cy
.
window
().
then
(
win
=>
{
const
swhIdsContext
=
win
.
swh
.
webapp
.
getSwhIdsContext
();
const
browseDirectoryUrl
=
swhIdsContext
.
directory
.
swhid_with_context_url
;
// Create a vault cooking task through the GUI
cy
.
get
(
'.modal-dialog'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@createVaultCookingTask'
);
// Check success alert is displayed
cy
.
get
(
'.alert-success'
)
.
should
(
'be.visible'
)
.
should
(
'contain'
,
'Archive cooking request successfully submitted.'
);
// Go to Downloads page
cy
.
visit
(
this
.
Urls
.
browse_vault
());
cy
.
wait
(
'@checkVaultCookingTask'
).
then
(()
=>
{
testStatus
(
this
.
directory
,
progressbarColors
[
'new'
],
'new'
,
'new'
);
});
cy
.
wait
(
'@checkVaultCookingTask'
).
then
(()
=>
{
testStatus
(
this
.
directory
,
progressbarColors
[
'pending'
],
'Processing...'
,
'pending'
);
});
cy
.
wait
(
'@checkVaultCookingTask'
).
then
(()
=>
{
testStatus
(
this
.
directory
,
progressbarColors
[
'done'
],
'done'
,
'done'
);
});
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
directory
)
}
.vault-origin a`
)
.
should
(
'contain'
,
this
.
origin
[
0
].
url
)
.
should
(
'have.attr'
,
'href'
,
`
${
this
.
Urls
.
browse_origin
()
}
?origin_url=
${
this
.
origin
[
0
].
url
}
`
);
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
directory
)
}
.vault-object-info a`
)
.
should
(
'have.text'
,
this
.
directory
)
.
should
(
'have.attr'
,
'href'
,
browseDirectoryUrl
);
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
directory
)
}
`
)
.
invoke
(
'attr'
,
'title'
)
.
should
(
'contain'
,
'the directory can be extracted'
);
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
directory
)
}
.vault-dl-link button`
)
.
click
();
cy
.
wait
(
'@fetchCookedArchive'
).
then
((
xhr
)
=>
{
assert
.
isNotNull
(
xhr
.
response
.
body
);
});
});
});
it
(
'should create a revision cooking task and report its status'
,
function
()
{
cy
.
adminLogin
();
// Browse a revision
cy
.
visit
(
this
.
revisionUrl
);
// Stub response to the vault API indicating to simulate archive download
cy
.
intercept
({
url
:
this
.
vaultFetchRevisionUrl
},
{
fixture
:
`
${
this
.
revision
.
replace
(
/:/g
,
'_'
)
}
.git.tar`
,
headers
:
{
'Content-disposition'
:
`attachment; filename=
${
this
.
revision
.
replace
(
/:/g
,
'_'
)
}
.git.tar`
,
'Content-Type'
:
'application/x-tar'
}
}).
as
(
'fetchCookedArchive'
);
// Stub responses when checking vault task status
const
checkVaultResponses
=
[
{
'exception'
:
'NotFoundExc'
},
this
.
genVaultRevCookingResponse
(
'new'
),
this
.
genVaultRevCookingResponse
(
'pending'
,
'Processing...'
),
this
.
genVaultRevCookingResponse
(
'done'
)
];
// trick to override the response of an intercepted request
// https://github.com/cypress-io/cypress/issues/9302
cy
.
intercept
(
'GET'
,
this
.
vaultRevisionUrl
,
req
=>
req
.
reply
(
checkVaultResponses
.
shift
()))
.
as
(
'checkVaultCookingTask'
);
// Stub responses when requesting the vault API to simulate
// a task has been created
cy
.
intercept
(
'POST'
,
this
.
vaultRevisionUrl
,
{
body
:
this
.
genVaultRevCookingResponse
(
'new'
)
}).
as
(
'createVaultCookingTask'
);
// Create a vault cooking task through the GUI
checkVaultCookingTask
(
'as git'
);
cy
.
window
().
then
(
win
=>
{
const
swhIdsContext
=
win
.
swh
.
webapp
.
getSwhIdsContext
();
const
browseRevisionUrl
=
swhIdsContext
.
revision
.
swhid_url
;
// Create a vault cooking task through the GUI
cy
.
get
(
'.modal.show'
)
.
type
(
'{enter}'
);
cy
.
wait
(
'@createVaultCookingTask'
);
// Check success alert is displayed
cy
.
get
(
'.alert-success'
)
.
should
(
'be.visible'
)
.
should
(
'contain'
,
'Archive cooking request successfully submitted.'
);
// Go to Downloads page
cy
.
visit
(
this
.
Urls
.
browse_vault
());
cy
.
wait
(
'@checkVaultCookingTask'
).
then
(()
=>
{
testStatus
(
this
.
revision
,
progressbarColors
[
'new'
],
'new'
,
'new'
);
});
cy
.
wait
(
'@checkVaultCookingTask'
).
then
(()
=>
{
testStatus
(
this
.
revision
,
progressbarColors
[
'pending'
],
'Processing...'
,
'pending'
);
});
cy
.
wait
(
'@checkVaultCookingTask'
).
then
(()
=>
{
testStatus
(
this
.
revision
,
progressbarColors
[
'done'
],
'done'
,
'done'
);
});
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
.vault-origin`
)
.
should
(
'have.text'
,
'unknown'
);
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
.vault-object-info a`
)
.
should
(
'have.text'
,
this
.
revision
)
.
should
(
'have.attr'
,
'href'
,
browseRevisionUrl
);
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
`
)
.
invoke
(
'attr'
,
'title'
)
.
should
(
'contain'
,
'the git repository can be imported'
);
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
.vault-dl-link button`
)
.
click
();
cy
.
wait
(
'@fetchCookedArchive'
).
then
((
xhr
)
=>
{
assert
.
isNotNull
(
xhr
.
response
.
body
);
});
});
});
it
(
'should create a directory cooking task from the release view'
,
function
()
{
// Browse a directory
cy
.
visit
(
this
.
releaseUrl
);
// Stub responses when checking vault task status
const
checkVaultResponses
=
[
{
'exception'
:
'NotFoundExc'
},
this
.
genVaultDirCookingResponse
(
'new'
)
];
// trick to override the response of an intercepted request
// https://github.com/cypress-io/cypress/issues/9302
cy
.
intercept
(
'GET'
,
this
.
vaultReleaseDirectoryUrl
,
req
=>
req
.
reply
(
checkVaultResponses
.
shift
()))
.
as
(
'checkVaultCookingTask'
);
// Stub responses when requesting the vault API to simulate
// a task has been created
cy
.
intercept
(
'POST'
,
this
.
vaultReleaseDirectoryUrl
,
{
body
:
this
.
genVaultDirCookingResponse
(
'new'
)
}).
as
(
'createVaultCookingTask'
);
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
// Create a vault cooking task through the GUI
cy
.
get
(
'.modal-dialog'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@createVaultCookingTask'
);
// Check success alert is displayed
cy
.
get
(
'.alert-success'
)
.
should
(
'be.visible'
)
.
should
(
'contain'
,
'Archive cooking request successfully submitted.'
);
});
it
(
'should create a directory cooking task with an email address'
,
function
()
{
// Browse a directory
cy
.
visit
(
this
.
directoryUrl
);
// Stub responses when checking vault task status
cy
.
intercept
(
'GET'
,
this
.
vaultDirectoryUrl
,
{
body
:
{
'exception'
:
'NotFoundExc'
}})
.
as
(
'checkVaultCookingTask'
);
// Stub responses when requesting the vault API to simulate
// a task has been created
cy
.
intercept
(
'POST'
,
this
.
vaultDirectoryUrl
+
'?email=foo%2Bbar%40example.org'
,
{
body
:
this
.
genVaultDirCookingResponse
(
'new'
)
}).
as
(
'createVaultCookingTask'
);
// Open vault cook directory modal
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
cy
.
wait
(
'@checkVaultCookingTask'
);
// Create a vault cooking task through the GUI and fill email input
cy
.
get
(
'#vault-cook-directory-modal input[type="email"]'
)
.
type
(
'foo+bar@example.org'
,
{
force
:
true
});
cy
.
get
(
'#vault-cook-directory-modal'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@createVaultCookingTask'
);
});
it
(
'should offer to recook an archive if no longer available for download'
,
function
()
{
updateVaultItemList
(
this
.
vaultItems
);
// Send 404 when fetching vault item
cy
.
intercept
({
url
:
this
.
vaultFetchRevisionUrl
},
{
statusCode
:
404
,
body
:
{
'exception'
:
'NotFoundExc'
,
'reason'
:
`Revision with ID '
${
this
.
revision
}
' not found.`
},
headers
:
{
'Content-Type'
:
'json'
}
}).
as
(
'fetchCookedArchive'
);
cy
.
visit
(
this
.
Urls
.
browse_vault
())
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
.vault-dl-link button`
)
.
click
();
cy
.
wait
(
'@fetchCookedArchive'
).
then
(()
=>
{
cy
.
intercept
(
'POST'
,
this
.
vaultRevisionUrl
,
{
body
:
this
.
genVaultRevCookingResponse
(
'new'
)
}).
as
(
'createVaultCookingTask'
);
cy
.
intercept
(
this
.
vaultRevisionUrl
,
{
body
:
this
.
genVaultRevCookingResponse
(
'new'
)
}).
as
(
'checkVaultCookingTask'
);
cy
.
get
(
'#vault-recook-object-modal > .modal-dialog'
)
.
should
(
'be.visible'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@checkVaultCookingTask'
)
.
then
(()
=>
{
testStatus
(
this
.
revision
,
progressbarColors
[
'new'
],
'new'
,
'new'
);
});
});
});
it
(
'should remove selected vault items'
,
function
()
{
updateVaultItemList
(
this
.
vaultItems
);
cy
.
visit
(
this
.
Urls
.
browse_vault
())
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
`
)
.
find
(
'input[type="checkbox"]'
)
.
click
({
force
:
true
});
cy
.
contains
(
'button'
,
'Remove selected tasks'
)
.
click
();
cy
.
get
(
`#vault-task-
${
CSS
.
escape
(
this
.
revision
)
}
`
)
.
should
(
'not.exist'
);
});
it
(
'should offer to immediately download a directory tarball if already cooked'
,
function
()
{
// Browse a directory
cy
.
visit
(
this
.
directoryUrl
);
// Stub response to the vault API to simulate archive download
cy
.
intercept
({
url
:
this
.
vaultFetchDirectoryUrl
},
{
fixture
:
`
${
this
.
directory
.
replace
(
/:/g
,
'_'
)
}
.tar.gz`
,
headers
:
{
'Content-disposition'
:
`attachment; filename=
${
this
.
directory
.
replace
(
/:/g
,
'_'
)
}
.tar.gz`
,
'Content-Type'
:
'application/gzip'
}
}).
as
(
'fetchCookedArchive'
);
// Stub responses when requesting the vault API to simulate
// the directory tarball has already been cooked
cy
.
intercept
(
this
.
vaultDirectoryUrl
,
{
body
:
this
.
genVaultDirCookingResponse
(
'done'
)
}).
as
(
'checkVaultCookingTask'
);
// Create a vault cooking task through the GUI
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
// Start archive download through the GUI
cy
.
get
(
'.modal-dialog'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@fetchCookedArchive'
);
});
it
(
'should offer to immediately download a bare revision git archive if already cooked'
,
function
()
{
cy
.
adminLogin
();
// Browse a directory
cy
.
visit
(
this
.
revisionUrl
);
// Stub response to the vault API to simulate archive download
cy
.
intercept
({
url
:
this
.
vaultFetchRevisionUrl
},
{
fixture
:
`
${
this
.
revision
.
replace
(
/:/g
,
'_'
)
}
.git.tar`
,
headers
:
{
'Content-disposition'
:
`attachment; filename=
${
this
.
revision
.
replace
(
/:/g
,
'_'
)
}
.git.tar`
,
'Content-Type'
:
'application/x-tar'
}
}).
as
(
'fetchCookedArchive'
);
// Stub responses when requesting the vault API to simulate
// the directory tarball has already been cooked
cy
.
intercept
(
this
.
vaultRevisionUrl
,
{
body
:
this
.
genVaultRevCookingResponse
(
'done'
)
}).
as
(
'checkVaultCookingTask'
);
checkVaultCookingTask
(
'as git'
);
// Start archive download through the GUI
cy
.
get
(
'.modal-dialog'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
click
();
cy
.
wait
(
'@fetchCookedArchive'
);
});
it
(
'should offer to recook an object if previous vault task failed'
,
function
()
{
cy
.
visit
(
this
.
directoryUrl
);
// Stub responses when requesting the vault API to simulate
// the last cooking of the directory tarball has failed
cy
.
intercept
(
this
.
vaultDirectoryUrl
,
{
body
:
this
.
genVaultDirCookingResponse
(
'failed'
)
}).
as
(
'checkVaultCookingTask'
);
cy
.
contains
(
'button'
,
'Download'
)
.
click
();
// Check that recooking the directory is offered to user
cy
.
get
(
'.modal-dialog'
)
.
contains
(
'button:visible'
,
'Ok'
)
.
should
(
'be.visible'
);
});
});
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Jul 4, 3:33 PM (1 w, 21 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3301481
Attached To
rDWAPPS Web applications
Event Timeline
Log In to Comment