Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VPN-2452 - Subscription Expiring Message #5273

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/functional_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
steps:
- name: Clone repository
uses: actions/checkout@v3
with:
with:
submodules: 'true'

- name: Install build dependecies
Expand Down Expand Up @@ -87,7 +87,7 @@ jobs:

functionaltests:
name: Functional tests
needs:
needs:
- build_test_app
runs-on: ubuntu-22.04
timeout-minutes: 45
Expand Down Expand Up @@ -143,7 +143,7 @@ jobs:
export HEADLESS=yes
export TZ=Europe/London
mkdir -p $ARTIFACT_DIR
xvfb-run -a npm run functionalTest -- ${{matrix.test.path}}
xvfb-run -a npm run functionalTest -- --retries 3 ${{matrix.test.path}}
env:
ARTIFACT_DIR: ${{ runner.temp }}/artifacts
MVPN_BIN: ./build/dummyvpn
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/wasm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
steps:
- name: Clone repository
uses: actions/checkout@v3
with:
with:
submodules: 'true'

- name: Install Qt
Expand Down Expand Up @@ -122,8 +122,8 @@ jobs:
with:
name: WebAssembly Build Qt6
# Destination path
path: wasm
path: wasm

- name: Build addons
shell: bash
run: ./scripts/addon/generate_all_tests.py -q /opt/$QTVERSION/gcc_64/bin
Expand All @@ -137,4 +137,4 @@ jobs:
command: |
export PATH=$GECKOWEBDRIVER:$(npm bin):$PATH
export HEADLESS=yes
xvfb-run -a npm run functionalTestWasm -- ${{matrix.test.path}}
xvfb-run -a npm run functionalTestWasm -- --retries 3 ${{matrix.test.path}}
3 changes: 3 additions & 0 deletions addons/message_subscription_expiring/contactSupportLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
((api) => {
api.urlOpener.openLink(api.urlOpener.LinkHelpSupport);
});
12 changes: 12 additions & 0 deletions addons/message_subscription_expiring/isExpiring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
(function(api, condition) {
// Show message only if within 1 week of expiring.
let weekBeforeExpireMSecs = api.subscriptionData.expiresOn - 1000 * 60 * 60 * 24;

if (Date.now() < api.subscriptionData.expiresOn &&
Date.now() >= weekBeforeExpireMSecs) {
api.addon.date = weekBeforeExpireMSecs;
condition.enable();
} else {
condition.disable();
}
})
3 changes: 3 additions & 0 deletions addons/message_subscription_expiring/manageAccountLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
((api) => {
api.urlOpener.openLink(api.urlOpener.LinkAccount);
});
36 changes: 36 additions & 0 deletions addons/message_subscription_expiring/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"api_version": "0.1",
"id": "message_subscription_expiring",
"name": "Subscription expiring message",
"type": "message",
"conditions": {
"javascript": "isExpiring.js"
},
"message": {
"id": "message_subscription_expiring",
"title": "Your subscription is ending soon",
"subtitle": "Your Mozilla VPN subscription will end in one week. After that, you’ll no longer be able to use Mozilla VPN.",
"badge": "warning",
"blocks": [
{
"id": "c_1",
"type": "text",
"content": "Don’t lose your subscription — please go to your account page to renew your access to Mozilla VPN. If you’re having any issues, please contact support to get help."
},
{
"id": "c_2",
"type": "button",
"style": "primary",
"content": "Manage account",
"javascript": "manageAccountLink.js"
},
{
"id": "c_3",
"type": "button",
"style": "link",
"content": "Contact support",
"javascript": "contactSupportLink.js"
}
]
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"scripts": {
"functionalTest": "mocha --require ./tests/functional/setupVpn.js --timeout 30000 --retries 3",
"functionalTestWasm": "mocha --require ./tests/functional/setupWasm.js --timeout 30000 --retries 3"
"functionalTest": "mocha --require ./tests/functional/setupVpn.js --timeout 30000",
"functionalTestWasm": "mocha --require ./tests/functional/setupWasm.js --timeout 30000"
},
"devDependencies": {
"body-parser": "^1.20.0",
Expand Down
16 changes: 12 additions & 4 deletions scripts/addon/generate_all_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,23 @@
args = parser.parse_args()

generateall_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "generate_all.py")
addons_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "tests", "functional", "addons")

for file in os.listdir(addons_path):
manifest_path = os.path.join(addons_path, file, "manifest.json")
# Generate production addons files
build_cmd = [sys.executable, generateall_path]
if args.qtpath:
build_cmd.append("-q")
build_cmd.append(args.qtpath)
subprocess.call(build_cmd)

# Generate test addons files
test_addons_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "tests", "functional", "addons")
for file in os.listdir(test_addons_path):
manifest_path = os.path.join(test_addons_path, file, "manifest.json")
if os.path.exists(manifest_path):
print(f"Ignoring path {file} because the manifest already exists.")
continue

build_cmd = [sys.executable, generateall_path, "-p", os.path.join(addons_path, file)]
build_cmd = [sys.executable, generateall_path, "-p", os.path.join(test_addons_path, file)]
if args.qtpath:
build_cmd.append("-q")
build_cmd.append(args.qtpath)
Expand Down
18 changes: 18 additions & 0 deletions src/apps/vpn/inspector/inspectorhandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,24 @@ static QList<InspectorCommand> s_commands{
return obj;
}},

InspectorCommand{"messages", "Returns a list of the loaded messages ids", 0,
[](InspectorHandler*, const QList<QByteArray>&) {
QJsonObject obj;

AddonManager* am = AddonManager::instance();
Q_ASSERT(am);

QJsonArray messages;
am->forEach([&](Addon* addon) {
if (addon->type() == "message") {
messages.append(addon->id());
}
});

obj["value"] = messages;
return obj;
}},

InspectorCommand{"translate", "Translate a string", 1,
[](InspectorHandler*, const QList<QByteArray>& arguments) {
QJsonObject obj;
Expand Down
8 changes: 8 additions & 0 deletions tests/functional/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,14 @@ module.exports = {
return json.value;
},

async messages() {
const json = await this._writeCommand('messages');
assert(
json.type === 'messages' && !('error' in json),
`Command failed: ${json.error}`);
return json.value;
},

async screenCapture() {
const json = await this._writeCommand('screen_capture');
assert(
Expand Down
16 changes: 9 additions & 7 deletions tests/functional/servers/addon.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const constants = require('../constants.js');
const fs = require('fs');
const path = require('path');

const ADDON_PATH = './tests/functional/addons';
const TEST_ADDONS_PATH = './tests/functional/addons';
const PROD_ADDONS_PATH = './addons';

// This function exposes all the files for a particular addon scenario through
// the addon server.
Expand All @@ -16,8 +17,7 @@ function createScenario(scenario, addonPath) {
if (!fs.existsSync(generatedPath)) {
const manifestPath = path.join(addonPath, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error(`No generated and not manifest file! ${
manifestPath} should exist! Have you executed \`./scripts/addon/generate_all_tests.py'?`);
throw new Error(`No generated and not manifest file! ${manifestPath} should exist! Have you executed \`./scripts/addon/generate_all_tests.py'?`);
}

const obj = {};
Expand Down Expand Up @@ -66,17 +66,19 @@ function createScenario(scenario, addonPath) {
let server = null;
module.exports = {
start(headerCheck = true) {
let scenarios = {};
// Generate production addon scenarios
let scenarios = { ...createScenario("prod", PROD_ADDONS_PATH) };

const dirs = fs.readdirSync(ADDON_PATH);
// Generate test addon scenarios
const dirs = fs.readdirSync(TEST_ADDONS_PATH);
for (const dir of dirs) {
const addonPath = path.join(ADDON_PATH, dir);
const addonPath = path.join(TEST_ADDONS_PATH, dir);
const stat = fs.statSync(addonPath);
if (!stat.isDirectory()) {
continue;
}

scenarios = {...scenarios, ...createScenario(dir, addonPath)};
scenarios = { ...scenarios, ...createScenario(dir, addonPath) };
}

const endpoints = {
Expand Down
2 changes: 2 additions & 0 deletions tests/functional/servers/guardian_endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const SubscriptionDetails = {
},
};

exports.SubscriptionDetails = SubscriptionDetails;

const VALIDATORS = {
guardianLoginVerify: {
type: 'object',
Expand Down
2 changes: 2 additions & 0 deletions tests/functional/setupVpn.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ async function startAndConnect() {
await vpn.connect(vpnWS, {hostname: '127.0.0.1'});
}

exports.startAndConnect = startAndConnect;

exports.mochaHooks = {
async beforeAll() {
// Check VPN app exists. If not, bail.
Expand Down
74 changes: 74 additions & 0 deletions tests/functional/testAddons.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
const assert = require('assert');
const queries = require('./queries.js');
const vpn = require('./helper.js');
const { SubscriptionDetails } = require('./servers/guardian_endpoints.js')
const { startAndConnect } = require('./setupVpn.js')

describe('Addons', function() {
this.ctx.authenticationNeeded = true;
Expand Down Expand Up @@ -127,4 +129,76 @@ describe('Addons', function() {
await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode') ===
exitCountryCode);
});

describe('test message_subscription_expiring addon condition', async () => {
async function checkForSubscriptionExpiringMessage(ctx, subscriptionExpirationCases, shouldBeAvailable) {
for (const expiresOn of subscriptionExpirationCases) {
const mockDetails = { ...SubscriptionDetails };
// We are faking a Stripe subscription, so this value is expected to be in seconds.
mockDetails.subscription.current_period_end = expiresOn / 1000;
ctx.guardianSubscriptionDetailsCallback = () => {
ctx.guardianOverrideEndpoints.GETs['/api/v1/vpn/subscriptionDetails'].status = 200;
ctx.guardianOverrideEndpoints
.GETs['/api/v1/vpn/subscriptionDetails']
.body = mockDetails;
};

// Restart the VPN to load the new sub details.
await vpn.quit();
await startAndConnect();

// Load all production addons.
// These are loaded all together, so we don't know the exact number of addons.
await vpn.resetAddons('prod');
await vpn.waitForCondition(async () => (
parseInt(await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) > 0
));

const loadedMessages = await vpn.messages();
const isSubscriptionExpiringMessageAvailable = loadedMessages.includes("message_subscription_expiring");

if (shouldBeAvailable) {
assert(isSubscriptionExpiringMessageAvailable);
} else {
assert(!isSubscriptionExpiringMessageAvailable);
}
}
}

it('message is enabled when subscription is about to expire', async () => {
// 1 to 7 days out from expiring.
const subscriptionExpirationCases = Array.from(
{ length: 7 },
(_, i) => Date.now() + 1000 * 60 * 60 * 24 * (i + 1)
);

await checkForSubscriptionExpiringMessage(this.ctx, subscriptionExpirationCases, true);
});

it('message is not enabled when subscription is not about to expire', async () => {
const subscriptionExpirationCases = [
// Seven days out + a minute from expiring.
Date.now() + 1000 * 60 * 60 * 24 * 7 + 1000 * 60,
// Eight days from expiring.
Date.now() + 1000 * 60 * 60 * 24 * 8,
// One month from expiring.
Date.now() + 1000 * 60 * 60 * 24 * 30,
]

await checkForSubscriptionExpiringMessage(this.ctx, subscriptionExpirationCases, false);
});

it('message is not enabled when subscription is already expired', async () => {
const subscriptionExpirationCases = [
// Literally, has just expired.
Date.now(),
// Has been expired for a day.
Date.now() - 1000 * 60 * 60 * 24,
// Has been expired for 30 days.
Date.now() - 1000 * 60 * 60 * 24 * 30,
]

await checkForSubscriptionExpiringMessage(this.ctx, subscriptionExpirationCases, false);
});
});
});