Skip to content
Draft
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: 4 additions & 2 deletions lib/features/bullnym/domain/bullnym_public_names.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ const Set<String> bullnymReservedNyms = {
};

/// Alias-only reservations in addition to [bullnymReservedNyms].
///
/// Mirrors the server's `RESERVED_ALIASES` layered over `RESERVED_NYMS`; both
/// sets are pinned against a fixture extracted from the server in
/// `test/features/bullnym/bullnym_reserved_names_parity_test.dart`.
const Set<String> bullnymReservedAliases = {
...bullnymReservedNyms,
'0',
'1',
'bullbitcoin',
'bull-bitcoin',
'bullpay',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,29 @@ class _LightningAddressActivationScreenState
final cubit = context.read<LightningAddressActivationCubit>();
if (online) {
await cubit.activateExisting();
} else {
await cubit.deactivate();
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(dialogContext.loc.lightningAddressTurnOffConfirmTitle),
content: Text(dialogContext.loc.lightningAddressTurnOffConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(dialogContext.loc.lightningAddressTurnOffConfirmCancel),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(dialogContext.loc.lightningAddressTurnOffConfirmSubmit),
),
],
),
);
if (!mounted || confirmed != true) return;
// The cubit re-checks the live status, so a deactivation that raced with
// this dialog is dropped there rather than re-issued here.
await cubit.deactivate();
}

String _failureMessage(
Expand Down
16 changes: 16 additions & 0 deletions localization/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -17986,5 +17986,21 @@
"walletDataIncompleteRestoreMessage": "This device still has your wallet records, but the seed that unlocks them is gone. Restore your wallet from your backup on this device to use it again.",
"@walletDataIncompleteRestoreMessage": {
"description": "Body copy shown when wallet records survive without their seed: the only remedy is restoring the wallet from a backup."
},
"lightningAddressTurnOffConfirmTitle": "Turn off Lightning Address?",
"@lightningAddressTurnOffConfirmTitle": {
"description": "Title of the Lightning Address-specific turn-off confirmation"
},
"lightningAddressTurnOffConfirmBody": "People will no longer be able to send funds to your Lightning Address. Your permanent names remain claimed, and your Donation Page and Point of Sale stay online and keep accepting payments.",
"@lightningAddressTurnOffConfirmBody": {
"description": "Explains effects and non-effects of turning off the Lightning Address"
},
"lightningAddressTurnOffConfirmCancel": "Keep online",
"@lightningAddressTurnOffConfirmCancel": {
"description": "Cancel action for turning off the Lightning Address"
},
"lightningAddressTurnOffConfirmSubmit": "Turn off",
"@lightningAddressTurnOffConfirmSubmit": {
"description": "Confirm action for turning off the Lightning Address"
}
}
91 changes: 91 additions & 0 deletions test/features/bullnym/bullnym_reserved_names_parity_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'dart:io';

import 'package:bb_mobile/features/bullnym/public/bullnym_facade.dart';
import 'package:flutter_test/flutter_test.dart';

// The client's reservation sets are an immediate-feedback filter in front of an
// authoritative server check, so a client set that is WIDER than the server's
// silently forbids names the server would happily grant, and a NARROWER one
// promises names the server will reject after the merchant has committed to
// them. Neither shows up as a client-side failure, which is why these lists are
// pinned rather than eyeballed.
//
// The mobile package cannot read the service source at test time, so the
// released wire contract's two lists are materialized in the fixture below.
// When the service contract changes, update the fixture and client together.
void main() {
final fixture =
jsonDecode(
File(
'test/features/bullnym/fixtures/server_reserved_names.json',
).readAsStringSync(),
)
as Map<String, dynamic>;
final serverNyms = (fixture['reserved_nyms'] as List).cast<String>().toSet();
final serverAliasOnly = (fixture['reserved_aliases'] as List)
.cast<String>()
.toSet();
// The server layers RESERVED_ALIASES over RESERVED_NYMS in
// `is_reserved_alias`, so the effective alias set is the union.
final serverAliases = {...serverNyms, ...serverAliasOnly};

group('reserved-name parity with the released bullnym server', () {
test('fixture pins the reserved-name contract revision', () {
expect(fixture['_contract'], 'reserved_names_v1');
});

test('reserved nyms match the server exactly, both directions', () {
expect(
bullnymReservedNyms.difference(serverNyms),
isEmpty,
reason: 'client blocks nyms the server allows',
);
expect(
serverNyms.difference(bullnymReservedNyms),
isEmpty,
reason: 'client allows nyms the server blocks',
);
});

test('reserved aliases match the server exactly, both directions', () {
expect(
bullnymReservedAliases.difference(serverAliases),
isEmpty,
reason: 'client blocks aliases the server allows',
);
expect(
serverAliases.difference(bullnymReservedAliases),
isEmpty,
reason: 'client allows aliases the server blocks',
);
});

test('every reserved name is rejected by the matching claim factory', () {
for (final value in serverNyms) {
expect(
() => BullnymPublicName.nymClaim(value),
throwsArgumentError,
reason: value,
);
}
for (final value in serverAliases) {
expect(
() => BullnymPublicName.aliasClaim(value),
throwsArgumentError,
reason: value,
);
}
});

test('single digits are claimable aliases, as the server permits', () {
// The server's own tests assert !is_reserved_alias("0") and
// !is_reserved_alias("1"); the client used to reserve both.
for (final value in ['0', '1']) {
expect(bullnymReservedAliases.contains(value), isFalse, reason: value);
expect(BullnymPublicName.aliasClaim(value).value, value);
expect(BullnymPublicName.nymClaim(value).value, value);
}
});
});
}
46 changes: 46 additions & 0 deletions test/features/bullnym/fixtures/server_reserved_names.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"_contract": "reserved_names_v1",
"reserved_nyms": [
"register",
"health",
"ready",
"version",
"webhook",
"lnurlp",
"api",
"img",
"donation-page",
"well-known",
"admin",
"static",
"assets",
"favicon",
"robots",
"sitemap",
"about",
"terms",
"privacy",
"support",
"help",
"login",
"logout",
"signup",
"settings",
"account",
"dashboard",
"test",
"i",
"invoice",
"invoices",
"pos",
"a"
],
"reserved_aliases": [
"bullbitcoin",
"bull-bitcoin",
"bullpay",
"bullnym",
"bull",
"bitcoin"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,41 +189,73 @@ void main() {
expect(cubit.loadCalls, 2);
});

testWidgets('turning off uses the same direct progress flow as turning on', (
testWidgets('turning off confirms first and says the other products stay', (
tester,
) async {
final cubit = await _pump(tester, _ownedState(online: true));

await tester.tap(find.text('Advanced settings'));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('lightning_address_online_switch')),
await _tapOnlineSwitch(tester);

expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Turn off Lightning Address?'), findsOneWidget);
expect(
find.text(
'People will no longer be able to send funds to your Lightning '
'Address. Your permanent names remain claimed, and your Donation Page '
'and Point of Sale stay online and keep accepting payments.',
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('lightning_address_online_switch')));
// Nothing is deactivated until the merchant confirms.
expect(cubit.deactivateCalls, 0);

await tester.tap(find.text('Turn off'));
await tester.pumpAndSettle();

expect(cubit.deactivateCalls, 1);
expect(find.byType(AlertDialog), findsNothing);
expect(cubit.deactivateCalls, 1);
});

testWidgets('turning on reuses the owned nym without another confirmation', (
testWidgets('cancelling the turn-off confirmation leaves it on', (
tester,
) async {
final cubit = await _pump(tester, _ownedState(online: false));
final cubit = await _pump(tester, _ownedState(online: true));

await tester.tap(find.text('Advanced settings'));
await _tapOnlineSwitch(tester);
await tester.tap(find.text('Keep online'));
await tester.pumpAndSettle();
await tester.ensureVisible(

expect(find.byType(AlertDialog), findsNothing);
expect(cubit.deactivateCalls, 0);
final switchTile = tester.widget<SwitchListTile>(
find.byKey(const Key('lightning_address_online_switch')),
);
await tester.tap(find.byKey(const Key('lightning_address_online_switch')));
await tester.pumpAndSettle();
expect(switchTile.value, isTrue);
});

testWidgets('turning on reuses the owned nym without another confirmation', (
tester,
) async {
final cubit = await _pump(tester, _ownedState(online: false));

await _tapOnlineSwitch(tester);

expect(find.byType(AlertDialog), findsNothing);
expect(cubit.activateExistingCalls, 1);
});
}

/// Opens Advanced Settings and flips the availability switch inside it.
Future<void> _tapOnlineSwitch(WidgetTester tester) async {
await tester.tap(find.text('Advanced settings'));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('lightning_address_online_switch')),
);
await tester.tap(find.byKey(const Key('lightning_address_online_switch')));
await tester.pumpAndSettle();
}

LightningAddressActivationState _ownedState({required bool online}) {
return LightningAddressActivationState(
status: online
Expand Down