diff --git a/assets/src/bundles/add_forge/add-request-history-item.ejs b/assets/src/bundles/add_forge/add-request-history-item.ejs
index b17bc1a0..172554c8 100644
--- a/assets/src/bundles/add_forge/add-request-history-item.ejs
+++ b/assets/src/bundles/add_forge/add-request-history-item.ejs
@@ -1,34 +1,34 @@
<%#
Copyright (C) 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
%>
-
diff --git a/cypress/integration/add-forge-now-request-dashboard.spec.js b/cypress/integration/add-forge-now-request-dashboard.spec.js
index 2acd0772..4c766a2e 100644
--- a/cypress/integration/add-forge-now-request-dashboard.spec.js
+++ b/cypress/integration/add-forge-now-request-dashboard.spec.js
@@ -1,230 +1,308 @@
/**
* Copyright (C) 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
*/
-let requestId, forgeDomain;
+let requestId;
+let requestForgeDomain;
+let requestInboundEmailAddress;
+
+const requestData = {
+ forge_type: 'bitbucket',
+ forge_url: 'test.example.com',
+ forge_contact_email: 'test@example.com',
+ forge_contact_name: 'test user',
+ submitter_forward_username: true,
+ forge_contact_comment: 'test comment'
+};
function createDummyRequest(urls) {
cy.task('db:add_forge_now:delete');
cy.userLogin();
- cy.getCookie('csrftoken').its('value').then((token) => {
+ return cy.getCookie('csrftoken').its('value').then((token) => {
cy.request({
method: 'POST',
url: urls.api_1_add_forge_request_create(),
- body: {
- forge_type: 'bitbucket',
- forge_url: 'test.example.com',
- forge_contact_email: 'test@example.com',
- forge_contact_name: 'test user',
- submitter_forward_username: true,
- forge_contact_comment: 'test comment'
- },
+ body: requestData,
headers: {
'X-CSRFToken': token
}
}).then((response) => {
// setting requestId and forgeDomain from response
requestId = response.body.id;
- forgeDomain = response.body.forge_domain;
+ requestForgeDomain = response.body.forge_domain;
+ requestInboundEmailAddress = response.body.inbound_email_address;
// logout the user
cy.visit(urls.swh_web_homepage());
cy.contains('a', 'logout').click();
});
});
}
+function genEmailSrc() {
+ return `Message-ID:
+Date: Tue, 19 Apr 2022 14:00:56 +0200
+MIME-Version: 1.0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
+ Thunderbird/91.8.0
+To: ${requestData.forge_contact_email}
+Cc: ${requestInboundEmailAddress}
+Reply-To: ${requestInboundEmailAddress}
+Subject: Software Heritage archival request for test.example.com
+Content-Language: en-US
+From: Test Admin
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+Dear forge administrator,
+
+The mission of Software Heritage is to collect, preserve and share all the
+publicly available source code (see https://www.softwareheritage.org for more
+information).
+
+We just received a request to add the forge hosted at https://test.example.com to the
+list of software origins that are archived, and it is our understanding that you
+are the contact person for this forge.
+
+In order to archive the forge contents, we will have to periodically pull the
+public repositories it contains and clone them into the
+Software Heritage archive.
+
+Would you be so kind as to reply to this message to acknowledge the reception
+of this email and let us know if there are any special steps we should take in
+order to properly archive the public repositories hosted on your infrastructure?
+
+Thank you in advance for your help.
+
+Kind regards,
+The Software Heritage team
+`;
+}
+
describe('Test add forge now request dashboard load', function() {
before(function() {
// Create an add-forge-request object in the DB
createDummyRequest(this.Urls);
});
beforeEach(function() {
const url = this.Urls.add_forge_now_request_dashboard(requestId);
// request dashboard require admin permissions to view
cy.adminLogin();
cy.intercept(`${this.Urls.api_1_add_forge_request_get(requestId)}**`).as('forgeRequestGet');
cy.visit(url);
});
it('should load add forge request details', function() {
cy.wait('@forgeRequestGet');
cy.get('#requestStatus')
.should('contain', 'Pending');
cy.get('#requestType')
.should('contain', 'bitbucket');
cy.get('#requestURL')
.should('contain', 'test.example.com');
cy.get('#requestContactEmail')
.should('contain', 'test@example.com');
cy.get('#requestContactName')
.should('contain', 'test user');
cy.get('#requestContactEmail')
.should('contain', 'test@example.com');
cy.get('#requestContactConsent')
.should('contain', 'true');
cy.get('#submitterMessage')
.should('contain', 'test comment');
});
it('should show send message link', function() {
cy.wait('@forgeRequestGet');
cy.get('#contactForgeAdmin')
.should('have.attr', 'emailto')
.and('include', 'test@example.com');
cy.get('#contactForgeAdmin')
.should('have.attr', 'emailsubject')
- .and('include', `Software Heritage archival request for ${forgeDomain}`);
+ .and('include', `Software Heritage archival request for ${requestForgeDomain}`);
});
it('should not show any error message', function() {
cy.wait('@forgeRequestGet');
cy.get('#fetchError')
.should('have.class', 'd-none');
cy.get('#requestDetails')
.should('not.have.class', 'd-none');
});
it('should show error message for an api error', function() {
// requesting with a non existing request ID
const invalidRequestId = requestId + 10;
const url = this.Urls.add_forge_now_request_dashboard(invalidRequestId);
cy.intercept(`${this.Urls.api_1_add_forge_request_get(invalidRequestId)}**`,
{statusCode: 400}).as('forgeAddInvalidRequest');
cy.visit(url);
cy.wait('@forgeAddInvalidRequest');
cy.get('#fetchError')
.should('not.have.class', 'd-none');
cy.get('#requestDetails')
.should('have.class', 'd-none');
});
it('should load add forge request history', function() {
cy.wait('@forgeRequestGet');
cy.get('#requestHistory')
.children()
.should('have.length', 1);
cy.get('#requestHistory')
.children()
.should('contain', 'New status: Pending');
cy.get('#requestHistory')
.should('contain', 'From user (SUBMITTER)');
});
it('should load possible next status', function() {
cy.wait('@forgeRequestGet');
// 3 possible next status and the comment option
cy.get('#decisionOptions')
.children()
.should('have.length', 4);
});
});
function populateAndSubmitForm() {
cy.get('#decisionOptions').select('WAITING_FOR_FEEDBACK');
cy.get('#updateComment').type('This is an update comment');
cy.get('#updateRequestForm').submit();
}
-describe('Test forge now request update', function() {
+describe('Test add forge now request update', function() {
beforeEach(function() {
- createDummyRequest(this.Urls);
-
- const url = this.Urls.add_forge_now_request_dashboard(requestId);
- cy.adminLogin();
- // intercept GET API on page load
- cy.intercept(`${this.Urls.api_1_add_forge_request_get(requestId)}**`).as('forgeRequestGet');
- // intercept update POST API
- cy.intercept('POST', `${this.Urls.api_1_add_forge_request_update(requestId)}**`).as('forgeRequestUpdate');
- cy.visit(url);
+ createDummyRequest(this.Urls).then(() => {
+
+ this.url = this.Urls.add_forge_now_request_dashboard(requestId);
+ cy.adminLogin();
+ // intercept GET API on page load
+ cy.intercept(`${this.Urls.api_1_add_forge_request_get(requestId)}**`).as('forgeRequestGet');
+ // intercept update POST API
+ cy.intercept('POST', `${this.Urls.api_1_add_forge_request_update(requestId)}**`).as('forgeRequestUpdate');
+ cy.visit(this.url).then(() => {
+ cy.wait('@forgeRequestGet');
+ });
+ });
});
it('should submit correct details', function() {
- cy.wait('@forgeRequestGet');
populateAndSubmitForm();
// Making sure posting the right data
cy.wait('@forgeRequestUpdate').its('request.body')
.should('include', 'new_status')
.should('include', 'text')
.should('include', 'WAITING_FOR_FEEDBACK');
});
it('should show success message', function() {
- cy.wait('@forgeRequestGet');
populateAndSubmitForm();
// Making sure showing the success message
cy.wait('@forgeRequestUpdate');
cy.get('#userMessage')
.should('contain', 'The request status has been updated')
.should('not.have.class', 'badge-danger')
.should('have.class', 'badge-success');
});
it('should update the dashboard after submit', function() {
- cy.wait('@forgeRequestGet');
populateAndSubmitForm();
// Making sure the UI is updated after the submit
cy.wait('@forgeRequestGet');
cy.get('#requestStatus')
.should('contain', 'Waiting for feedback');
cy.get('#requestHistory')
.children()
.should('have.length', 2);
cy.get('#requestHistory')
.children()
.should('contain', 'New status: Waiting for feedback');
cy.get('#requestHistory')
.children()
.should('contain', 'This is an update comment');
cy.get('#requestHistory')
.children()
.should('contain', 'Status changed to: Waiting for feedback');
cy.get('#decisionOptions')
.children()
.should('have.length', 2);
});
+ it('should update the dashboard after receiving email', function() {
+ const emailSrc = genEmailSrc();
+ cy.task('processAddForgeNowInboundEmail', emailSrc);
+
+ // Refresh page and wait for the async request to complete
+ cy.visit(this.url);
+ cy.wait('@forgeRequestGet');
+
+ cy.get('#requestHistory')
+ .children()
+ .should('have.length', 2);
+
+ cy.get('#historyItem1')
+ .click()
+ .should('contain', 'New status: Waiting for feedback');
+
+ cy.get('#historyItemBody1')
+ .find('a')
+ .should('contain', 'Open original message in email client')
+ .should('have.prop', 'href').and('contain', '/message-source/').then(function(href) {
+ cy.request(href).then((response) => {
+ expect(response.headers['content-type'])
+ .to.equal('text/email');
+
+ expect(response.headers['content-disposition'])
+ .to.match(/filename="add-forge-now-test.example.com-message\d+.eml"/);
+
+ expect(response.body)
+ .to.equal(emailSrc);
+ });
+ });
+ });
+
it('should show an error on API failure', function() {
cy.intercept('POST',
`${this.Urls.api_1_add_forge_request_update(requestId)}**`,
{forceNetworkError: true})
.as('updateFailedRequest');
cy.get('#updateComment').type('This is an update comment');
cy.get('#updateRequestForm').submit();
cy.wait('@updateFailedRequest');
cy.get('#userMessage')
.should('contain', 'Sorry; Updating the request failed')
.should('have.class', 'badge-danger')
.should('not.have.class', 'badge-success');
});
});
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
index fac4b035..a7f7146b 100644
--- a/cypress/plugins/index.js
+++ b/cypress/plugins/index.js
@@ -1,165 +1,176 @@
/**
* 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 axios = require('axios');
+const {execFileSync} = require('child_process');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
async function httpGet(url) {
const response = await axios.get(url);
return response.data;
}
async function getMetadataForOrigin(originUrl, baseUrl) {
const originVisitsApiUrl = `${baseUrl}/api/1/origin/${originUrl}/visits`;
const originVisits = await httpGet(originVisitsApiUrl);
const lastVisit = originVisits[0];
const snapshotApiUrl = `${baseUrl}/api/1/snapshot/${lastVisit.snapshot}`;
const lastOriginSnapshot = await httpGet(snapshotApiUrl);
let revision = lastOriginSnapshot.branches.HEAD.target;
if (lastOriginSnapshot.branches.HEAD.target_type === 'alias') {
revision = lastOriginSnapshot.branches[revision].target;
}
const revisionApiUrl = `${baseUrl}/api/1/revision/${revision}`;
const lastOriginHeadRevision = await httpGet(revisionApiUrl);
return {
'directory': lastOriginHeadRevision.directory,
'revision': lastOriginHeadRevision.id,
'snapshot': lastOriginSnapshot.id
};
};
function getDatabase() {
return new sqlite3.Database('./swh-web-test.sqlite3');
}
module.exports = (on, config) => {
require('@cypress/code-coverage/task')(on, config);
// produce JSON files prior launching browser in order to dynamically generate tests
on('before:browser:launch', function(browser, launchOptions) {
return new Promise((resolve) => {
const p1 = axios.get(`${config.baseUrl}/tests/data/content/code/extensions/`);
const p2 = axios.get(`${config.baseUrl}/tests/data/content/code/filenames/`);
Promise.all([p1, p2])
.then(function(responses) {
fs.writeFileSync('cypress/fixtures/source-file-extensions.json', JSON.stringify(responses[0].data));
fs.writeFileSync('cypress/fixtures/source-file-names.json', JSON.stringify(responses[1].data));
resolve();
});
});
});
on('task', {
getSwhTestsData: async() => {
if (!global.swhTestsData) {
const swhTestsData = {};
swhTestsData.unarchivedRepo = {
url: 'https://github.com/SoftwareHeritage/swh-web',
type: 'git',
revision: '7bf1b2f489f16253527807baead7957ca9e8adde',
snapshot: 'd9829223095de4bb529790de8ba4e4813e38672d',
rootDirectory: '7d887d96c0047a77e2e8c4ee9bb1528463677663',
content: [{
sha1git: 'b203ec39300e5b7e97b6e20986183cbd0b797859'
}]
};
swhTestsData.origin = [{
url: 'https://github.com/memononen/libtess2',
type: 'git',
content: [{
path: 'Source/tess.h'
}, {
path: 'premake4.lua'
}],
directory: [{
path: 'Source',
id: 'cd19126d815470b28919d64b2a8e6a3e37f900dd'
}],
revisions: [],
invalidSubDir: 'Source1'
}, {
url: 'https://github.com/wcoder/highlightjs-line-numbers.js',
type: 'git',
content: [{
path: 'src/highlightjs-line-numbers.js'
}],
directory: [],
revisions: ['1c480a4573d2a003fc2630c21c2b25829de49972'],
release: {
name: 'v2.6.0',
id: '6877028d6e5412780517d0bfa81f07f6c51abb41',
directory: '5b61d50ef35ca9a4618a3572bde947b8cccf71ad'
}
}];
for (const origin of swhTestsData.origin) {
const metadata = await getMetadataForOrigin(origin.url, config.baseUrl);
const directoryApiUrl = `${config.baseUrl}/api/1/directory/${metadata.directory}`;
origin.dirContent = await httpGet(directoryApiUrl);
origin.rootDirectory = metadata.directory;
origin.revisions.push(metadata.revision);
origin.snapshot = metadata.snapshot;
for (const content of origin.content) {
const contentPathApiUrl = `${config.baseUrl}/api/1/directory/${origin.rootDirectory}/${content.path}`;
const contentMetaData = await httpGet(contentPathApiUrl);
content.name = contentMetaData.name.split('/').slice(-1)[0];
content.sha1git = contentMetaData.target;
content.directory = contentMetaData.dir_id;
const rawFileUrl = `${config.baseUrl}/browse/content/sha1_git:${content.sha1git}/raw/?filename=${content.name}`;
const fileText = await httpGet(rawFileUrl);
const fileLines = fileText.split('\n');
content.numberLines = fileLines.length;
if (!fileLines[content.numberLines - 1]) {
// If last line is empty its not shown
content.numberLines -= 1;
}
}
}
global.swhTestsData = swhTestsData;
}
return global.swhTestsData;
},
'db:user_mailmap:delete': () => {
const db = getDatabase();
db.serialize(function() {
db.run('DELETE FROM user_mailmap');
db.run('DELETE FROM user_mailmap_event');
});
db.close();
return true;
},
'db:user_mailmap:mark_processed': () => {
const db = getDatabase();
db.serialize(function() {
db.run('UPDATE user_mailmap SET mailmap_last_processing_date=datetime("now", "+1 hour")');
});
db.close();
return true;
},
'db:add_forge_now:delete': () => {
const db = getDatabase();
db.serialize(function() {
db.run('DELETE FROM add_forge_request_history');
db.run('DELETE FROM sqlite_sequence WHERE name="add_forge_request_history"');
});
db.serialize(function() {
db.run('DELETE FROM add_forge_request');
db.run('DELETE FROM sqlite_sequence WHERE name="add_forge_request"');
});
db.close();
return true;
+ },
+ processAddForgeNowInboundEmail(emailSrc) {
+ try {
+ execFileSync('django-admin',
+ ['process_inbound_email', '--settings=swh.web.settings.tests'],
+ {input: emailSrc});
+ return true;
+ } catch (_) {
+ return false;
+ }
}
});
return config;
};
diff --git a/swh/web/add_forge_now/apps.py b/swh/web/add_forge_now/apps.py
index 7a27e3d4..76a91ee6 100644
--- a/swh/web/add_forge_now/apps.py
+++ b/swh/web/add_forge_now/apps.py
@@ -1,13 +1,19 @@
# Copyright (C) 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
from django.apps import AppConfig
APP_LABEL = "swh_web_add_forge_now"
class AddForgeNowConfig(AppConfig):
name = "swh.web.add_forge_now"
label = APP_LABEL
+
+ def ready(self):
+ from ..inbound_email.signals import email_received
+ from .signal_receivers import handle_inbound_message
+
+ email_received.connect(handle_inbound_message)
diff --git a/swh/web/add_forge_now/signal_receivers.py b/swh/web/add_forge_now/signal_receivers.py
new file mode 100644
index 00000000..dd38f729
--- /dev/null
+++ b/swh/web/add_forge_now/signal_receivers.py
@@ -0,0 +1,77 @@
+# Copyright (C) 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
+
+from email.message import EmailMessage
+from typing import Type
+
+from ..config import get_config
+from ..inbound_email.signals import EmailProcessingStatus
+from ..inbound_email.utils import get_message_plaintext, get_pks_from_message
+from .apps import APP_LABEL
+from .models import Request, RequestActorRole, RequestHistory, RequestStatus
+
+
+def handle_inbound_message(sender: Type, **kwargs) -> EmailProcessingStatus:
+ """Handle inbound email messages for add forge now.
+
+ This uses the from field in the message to set the actor for the new entry in the
+ request history. We also unconditionally advance the status of requests in the
+ ``PENDING`` or ``WAITING_FOR_FEEDBACK`` states.
+
+ The message source is saved in the request history as an escape hatch if something
+ goes wrong during processing.
+
+ """
+
+ message = kwargs["message"]
+ assert isinstance(message, EmailMessage)
+
+ base_address = get_config()["add_forge_now"]["email_address"]
+
+ pks = get_pks_from_message(
+ salt=APP_LABEL, base_address=base_address, message=message
+ )
+
+ if not pks:
+ return EmailProcessingStatus.IGNORED
+
+ for pk in pks:
+ try:
+ request = Request.objects.get(pk=pk)
+ except Request.DoesNotExist:
+ continue
+
+ request_updated = False
+
+ message_plaintext = get_message_plaintext(message)
+ if message_plaintext:
+ history_text = message_plaintext.decode("utf-8", errors="replace")
+ else:
+ history_text = (
+ "Could not parse the message contents, see the original message."
+ )
+
+ history_entry = RequestHistory(
+ request=request,
+ actor=str(message["from"]),
+ actor_role=RequestActorRole.EMAIL.name,
+ text=history_text,
+ message_source=bytes(message),
+ )
+
+ new_status = {
+ RequestStatus.PENDING: RequestStatus.WAITING_FOR_FEEDBACK,
+ RequestStatus.WAITING_FOR_FEEDBACK: RequestStatus.FEEDBACK_TO_HANDLE,
+ }.get(RequestStatus[request.status])
+
+ if new_status:
+ request.status = history_entry.new_status = new_status.name
+ request_updated = True
+
+ history_entry.save()
+ if request_updated:
+ request.save()
+
+ return EmailProcessingStatus.PROCESSED