Compare commits
10 Commits
didview-in
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c97821a | ||
|
|
40fa38a9ce | ||
|
|
96e4d3c394 | ||
|
|
c4f2bb5e3a | ||
|
|
f51408e32a | ||
| 6f9847b524 | |||
| 01279b61f5 | |||
| 43e7bc1c12 | |||
|
|
aa55588cbb | ||
|
|
5f63e05090 |
26
doc/duplicate-account-import-implementation.md
Normal file
26
doc/duplicate-account-import-implementation.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## What Works (Evidence)
|
||||
|
||||
- ✅ **ImportAccountView duplicate check**
|
||||
- **Time**: 2025-01-27T14:30:00Z
|
||||
- **Evidence**: Added `checkForDuplicateAccount()` method with DID derivation and database query
|
||||
- **Verify at**: `src/views/ImportAccountView.vue:180-200`
|
||||
|
||||
- ✅ **ImportAccountView error handling**
|
||||
- **Time**: 2025-01-27T15:00:00Z
|
||||
- **Evidence**: Enhanced error handling to catch duplicate errors from saveNewIdentity and display user-friendly warnings
|
||||
- **Verify at**: `src/views/ImportAccountView.vue:230-240`
|
||||
|
||||
- ✅ **ImportDerivedAccountView duplicate check**
|
||||
- **Time**: 2025-01-27T14:30:00Z
|
||||
- **Evidence**: Added duplicate check in `incrementDerivation()` method
|
||||
- **Verify at**: `src/views/ImportDerivedAccountView.vue:170-190`
|
||||
|
||||
- ✅ **saveNewIdentity safety check**
|
||||
- **Time**: 2025-01-27T14:30:00Z
|
||||
- **Evidence**: Added database query before INSERT operation
|
||||
- **Verify at**: `src/libs/util.ts:625-635`
|
||||
|
||||
- ✅ **User-friendly error messages**
|
||||
- **Time**: 2025-01-27T14:30:00Z
|
||||
- **Evidence**: Clear warning messages in both import views
|
||||
- **Verify at**: ImportAccountView and ImportDerivedAccountView notification calls
|
||||
@@ -1689,3 +1689,19 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
|
||||
title: "They're Added To Your List",
|
||||
message: "Would you like to go to the main page now?",
|
||||
};
|
||||
|
||||
// ImportAccountView.vue specific constants
|
||||
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
|
||||
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
|
||||
title: "Account Already Imported",
|
||||
message:
|
||||
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
|
||||
};
|
||||
|
||||
// ImportDerivedAccountView.vue specific constants
|
||||
// Used in: ImportDerivedAccountView.vue (incrementDerivation method - duplicate derived account warning)
|
||||
export const NOTIFY_DUPLICATE_DERIVED_ACCOUNT = {
|
||||
title: "Derived Account Already Exists",
|
||||
message:
|
||||
"This derived account already exists. Please try a different derivation path.",
|
||||
};
|
||||
|
||||
@@ -626,6 +626,18 @@ export async function saveNewIdentity(
|
||||
// add to the new sql db
|
||||
const platformService = await getPlatformService();
|
||||
|
||||
// Check if account already exists before attempting to save
|
||||
const existingAccount = await platformService.dbQuery(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[identity.did],
|
||||
);
|
||||
|
||||
if (existingAccount?.values?.length) {
|
||||
throw new Error(
|
||||
`Account with DID ${identity.did} already exists. Cannot import duplicate account.`,
|
||||
);
|
||||
}
|
||||
|
||||
const secrets = await platformService.dbQuery(
|
||||
`SELECT secretBase64 FROM secret`,
|
||||
);
|
||||
@@ -660,10 +672,8 @@ export async function saveNewIdentity(
|
||||
|
||||
await platformService.insertNewDidIntoSettings(identity.did);
|
||||
} catch (error) {
|
||||
logger.error("Failed to update default settings:", error);
|
||||
throw new Error(
|
||||
"Failed to set default settings. Please try again or restart the app.",
|
||||
);
|
||||
logger.error("Failed to save new identity:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,15 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
|
||||
|
||||
/**
|
||||
* Import Account View Component
|
||||
@@ -198,6 +203,16 @@ export default class ImportAccountView extends Vue {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for duplicate account before importing
|
||||
const isDuplicate = await this.checkForDuplicateAccount();
|
||||
if (isDuplicate) {
|
||||
this.notify.warning(
|
||||
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await importFromMnemonic(
|
||||
this.mnemonic,
|
||||
this.derivationPath,
|
||||
@@ -223,12 +238,64 @@ export default class ImportAccountView extends Vue {
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (error: unknown) {
|
||||
this.$logError("Import failed: " + error);
|
||||
|
||||
// Check if this is a duplicate account error from saveNewIdentity
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes("already exists") &&
|
||||
errorMessage.includes("Cannot import duplicate account")
|
||||
) {
|
||||
this.notify.warning(
|
||||
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.notify.error(
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
"Failed to import account.",
|
||||
errorMessage || "Failed to import account.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the account to be imported already exists
|
||||
*
|
||||
* Derives the DID from the mnemonic and checks if it exists in the database
|
||||
* @returns Promise<boolean> - True if account already exists, false otherwise
|
||||
*/
|
||||
private async checkForDuplicateAccount(): Promise<boolean> {
|
||||
try {
|
||||
// Derive the address and create the identifier to get the DID
|
||||
const [address, privateHex, publicHex] = deriveAddress(
|
||||
this.mnemonic.trim().toLowerCase(),
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
const newId = newIdentifier(
|
||||
address,
|
||||
publicHex,
|
||||
privateHex,
|
||||
this.derivationPath,
|
||||
);
|
||||
const didToCheck = newId.did;
|
||||
|
||||
// Check if an account with this DID already exists
|
||||
const existingAccount = await this.$query(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[didToCheck],
|
||||
);
|
||||
|
||||
return existingAccount?.values?.length > 0;
|
||||
} catch (error) {
|
||||
// If we can't check for duplicates (e.g., invalid mnemonic),
|
||||
// let the import process handle the error
|
||||
this.$logError("Error checking for duplicate account: " + error);
|
||||
// Return false to let the import process continue and handle the error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -91,6 +91,7 @@ import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
|
||||
import {
|
||||
NOTIFY_ACCOUNT_DERIVATION_SUCCESS,
|
||||
NOTIFY_ACCOUNT_DERIVATION_ERROR,
|
||||
NOTIFY_DUPLICATE_DERIVED_ACCOUNT,
|
||||
} from "@/constants/notifications";
|
||||
|
||||
@Component({
|
||||
@@ -171,6 +172,16 @@ export default class ImportAccountView extends Vue {
|
||||
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
|
||||
|
||||
try {
|
||||
// Check for duplicate account before creating
|
||||
const isDuplicate = await this.checkForDuplicateAccount(newId.did);
|
||||
if (isDuplicate) {
|
||||
this.notify.warning(
|
||||
NOTIFY_DUPLICATE_DERIVED_ACCOUNT.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await saveNewIdentity(newId, mne, newDerivPath);
|
||||
|
||||
// record that as the active DID
|
||||
@@ -192,5 +203,27 @@ export default class ImportAccountView extends Vue {
|
||||
this.notify.error(NOTIFY_ACCOUNT_DERIVATION_ERROR.message, TIMEOUTS.LONG);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the account to be created already exists
|
||||
*
|
||||
* @param did - The DID to check for duplicates
|
||||
* @returns Promise<boolean> - True if account already exists, false otherwise
|
||||
*/
|
||||
private async checkForDuplicateAccount(did: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if an account with this DID already exists
|
||||
const existingAccount = await this.$query(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[did],
|
||||
);
|
||||
|
||||
return existingAccount?.values?.length > 0;
|
||||
} catch (error) {
|
||||
// If we can't check for duplicates, let the save process handle the error
|
||||
this.$logError("Error checking for duplicate account: " + error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<!-- First, offers on the left-->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div v-if="activeDid && isRegistered" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
data-testId="offerButton"
|
||||
@@ -243,13 +243,19 @@
|
||||
:project-name="name"
|
||||
/>
|
||||
|
||||
<h3 class="text-lg font-bold mb-3 mt-4">Offered To This Idea</h3>
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">
|
||||
Offered To This Idea
|
||||
</h3>
|
||||
|
||||
<div v-if="offersToThis.length === 0">
|
||||
(None yet. Wanna
|
||||
<span class="cursor-pointer text-blue-500" @click="openOfferDialog()"
|
||||
>offer something... especially if others join you</span
|
||||
>?)
|
||||
<div v-if="offersToThis.length === 0" class="text-sm">
|
||||
(None yet.<span v-if="activeDid && isRegistered">
|
||||
Wanna
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
@click="openOfferDialog()"
|
||||
>offer something… especially if others join you</span
|
||||
>?</span
|
||||
>)
|
||||
</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
@@ -314,7 +320,7 @@
|
||||
<!-- Now, gives TO this project in the middle -->
|
||||
<!-- (similar to "FROM" gift display below) -->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-to">
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div v-if="activeDid && isRegistered" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@@ -325,7 +331,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4">Given To This Project</h3>
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">
|
||||
Given To This Project
|
||||
</h3>
|
||||
|
||||
<div v-if="givesToThis.length === 0" class="text-sm">
|
||||
(None yet. If you've seen something, say something by clicking a
|
||||
@@ -476,7 +484,7 @@
|
||||
<!-- Finally, gives FROM this project on the right -->
|
||||
<!-- (similar to "TO" gift display above) -->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md" data-testId="gives-from">
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div v-if="activeDid && isRegistered" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@@ -494,11 +502,13 @@
|
||||
:is-from-project-view="true"
|
||||
/>
|
||||
|
||||
<h3 class="text-lg font-bold mb-3 mt-4">
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">
|
||||
Benefitted From This Project
|
||||
</h3>
|
||||
|
||||
<div v-if="givesProvidedByThis.length === 0">(None yet.)</div>
|
||||
<div v-if="givesProvidedByThis.length === 0" class="text-sm">
|
||||
(None yet.)
|
||||
</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
|
||||
@@ -69,8 +69,7 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
|
||||
import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUtils';
|
||||
import { createContactName, generateNewEthrUser, importUser, importUserFromAccount } from './testUtils';
|
||||
import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications';
|
||||
|
||||
test('Check activity feed - check that server is running', async ({ page }) => {
|
||||
@@ -185,35 +184,20 @@ test('Check invalid DID shows error and redirects', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('Check User 0 can register a random person', async ({ page }) => {
|
||||
await importUser(page, '00');
|
||||
const newDid = await generateAndRegisterEthrUser(page);
|
||||
expect(newDid).toContain('did:ethr:');
|
||||
const newDid = await generateNewEthrUser(page); // generate a new user
|
||||
|
||||
await page.goto('./');
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
await page.getByRole('button', { name: 'Person' }).click();
|
||||
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
|
||||
await page.getByPlaceholder('What was given').fill('Gave me access!');
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
// now ensure that alert goes away
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss alert
|
||||
await expect(page.getByText('That gift was recorded.')).toBeHidden();
|
||||
await importUserFromAccount(page, "00"); // switch to User Zero
|
||||
|
||||
// now delete the contact to test that pages still do reasonable things
|
||||
await deleteContact(page, newDid);
|
||||
// go the activity page for this new person
|
||||
await page.goto('./did/' + encodeURIComponent(newDid));
|
||||
// maybe replace by: const popupPromise = page.waitForEvent('popup');
|
||||
let error;
|
||||
try {
|
||||
await page.waitForSelector('div[role="alert"]', { timeout: 2000 });
|
||||
error = new Error('Error alert should not show.');
|
||||
} catch (error) {
|
||||
// success
|
||||
} finally {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// As User Zero, add the new user as a contact
|
||||
await page.goto('./contacts');
|
||||
const contactName = createContactName(newDid);
|
||||
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(`${newDid}, ${contactName}`);
|
||||
await expect(page.locator('button > svg.fa-plus')).toBeVisible();
|
||||
await page.locator('button > svg.fa-plus').click();
|
||||
await expect(page.locator('div[role="alert"] h4:has-text("Success")')).toBeVisible(); // wait for info alert to be visible…
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // …and dismiss it
|
||||
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
|
||||
await page.locator('div[role="alert"] button:text-is("Yes")').click(); // Register new contact
|
||||
await page.locator('div[role="alert"] button:text-is("No, Not Now")').click(); // Dismiss export data prompt
|
||||
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
|
||||
});
|
||||
|
||||
63
test-playwright/03-duplicate-import-test.spec.ts
Normal file
63
test-playwright/03-duplicate-import-test.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { importUserFromAccount, getTestUserData } from './testUtils';
|
||||
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from '../src/constants/notifications';
|
||||
|
||||
/**
|
||||
* Test duplicate account import functionality
|
||||
*
|
||||
* This test verifies that:
|
||||
* 1. A user can successfully import an account the first time
|
||||
* 2. Attempting to import the same account again shows a warning message
|
||||
* 3. The duplicate import is prevented
|
||||
*/
|
||||
test.describe('Duplicate Account Import', () => {
|
||||
test('should prevent importing the same account twice', async ({ page }) => {
|
||||
const userData = getTestUserData("00");
|
||||
|
||||
// First import - should succeed
|
||||
await page.goto("./start");
|
||||
await page.getByText("You have a seed").click();
|
||||
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
// Verify first import was successful
|
||||
await expect(page.getByRole("code")).toContainText(userData.did);
|
||||
|
||||
// Navigate back to start page for second import attempt
|
||||
await page.goto("./start");
|
||||
await page.getByText("You have a seed").click();
|
||||
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
// Verify duplicate import shows warning message
|
||||
// The warning can appear either from the pre-check or from the saveNewIdentity error handling
|
||||
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).toBeVisible();
|
||||
|
||||
// Verify we're still on the import page (not redirected to account)
|
||||
await expect(page.getByPlaceholder("Seed Phrase")).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow importing different accounts', async ({ page }) => {
|
||||
const userZeroData = getTestUserData("00");
|
||||
const userOneData = getTestUserData("01");
|
||||
|
||||
// Import first user
|
||||
await page.goto("./start");
|
||||
await page.getByText("You have a seed").click();
|
||||
await page.getByPlaceholder("Seed Phrase").fill(userZeroData.seedPhrase);
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
// Verify first import was successful
|
||||
await expect(page.getByRole("code")).toContainText(userZeroData.did);
|
||||
|
||||
// Navigate back to start page for second user import
|
||||
await page.goto("./start");
|
||||
await page.getByText("You have a seed").click();
|
||||
await page.getByPlaceholder("Seed Phrase").fill(userOneData.seedPhrase);
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
// Verify second import was successful (should not show duplicate warning)
|
||||
await expect(page.getByRole("code")).toContainText(userOneData.did);
|
||||
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,7 @@ export async function switchToUser(page: Page, did: string): Promise<void> {
|
||||
await page.getByTestId("didWrapper").locator('code:has-text("did:")');
|
||||
}
|
||||
|
||||
function createContactName(did: string): string {
|
||||
export function createContactName(did: string): string {
|
||||
return "User " + did.slice(11, 14);
|
||||
}
|
||||
|
||||
@@ -144,30 +144,6 @@ export async function generateNewEthrUser(page: Page): Promise<string> {
|
||||
return newDid;
|
||||
}
|
||||
|
||||
// Generate a new random user and register them.
|
||||
// Note that this makes 000 the active user. Use switchToUser to switch to this DID.
|
||||
export async function generateAndRegisterEthrUser(page: Page): Promise<string> {
|
||||
const newDid = await generateNewEthrUser(page);
|
||||
|
||||
await importUser(page, "000"); // switch to user 000
|
||||
|
||||
await page.goto("./contacts");
|
||||
const contactName = createContactName(newDid);
|
||||
await page
|
||||
.getByPlaceholder("URL or DID, Name, Public Key")
|
||||
.fill(`${newDid}, ${contactName}`);
|
||||
await page.locator("button > svg.fa-plus").click();
|
||||
// register them
|
||||
await page.locator('div[role="alert"] button:text-is("Yes")').click();
|
||||
// wait for it to disappear because the next steps may depend on alerts being gone
|
||||
await expect(
|
||||
page.locator('div[role="alert"] button:text-is("Yes")')
|
||||
).toBeHidden();
|
||||
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
|
||||
|
||||
return newDid;
|
||||
}
|
||||
|
||||
// Function to generate a random string of specified length
|
||||
export async function generateRandomString(length: number): Promise<string> {
|
||||
return Math.random()
|
||||
|
||||
Reference in New Issue
Block a user