From 5c93456c060b902d3f5c548ad73052e3e54be49f Mon Sep 17 00:00:00 2001 From: Darian Bailey Date: Tue, 20 May 2025 19:21:09 -0400 Subject: [PATCH 1/2] create get domains route that fetches all domains for an admin. add tests and docs --- data/docs/get-domains.mdx | 72 ++++ pages/api/[network]/get-domains.js | 104 +++++ pages/api/[network]/get-domains.test.js | 526 ++++++++++++++++++++++++ 3 files changed, 702 insertions(+) create mode 100644 data/docs/get-domains.mdx create mode 100644 pages/api/[network]/get-domains.js create mode 100644 pages/api/[network]/get-domains.test.js diff --git a/data/docs/get-domains.mdx b/data/docs/get-domains.mdx new file mode 100644 index 0000000..bdd6d68 --- /dev/null +++ b/data/docs/get-domains.mdx @@ -0,0 +1,72 @@ +# Get Domains + +This GET route retrieves all domains where the specified address is an admin. It returns information about each domain including address, contenthash, text records, and coin types. + +## Parameters + +| Parameter | Type | Required | Description | +| --------------- | ------ | -------- | ----------------------------------------------------------------------------------- | +| `admin-address` | string | Yes | The Ethereum address that is an admin of the domains to retrieve. | +| `limit` | number | No | The number of domains to return (default: 50, max: 1000). | +| `offset` | number | No | The number of domains to skip (default: 0). | +| `text_records` | string | No | Set to "0" to exclude text records and coin types from the response (default: "1"). | + +## Response + +The response is an array of domain objects with the following properties: + +| Property | Type | Description | +| -------------- | ------ | ------------------------------------------------------------------------------- | +| `domain` | string | The domain name (e.g., "namestone.xyz"). | +| `address` | string | The Ethereum address the domain resolves to. | +| `contenthash` | string | The IPFS or IPNS contenthash for the domain's website, if set. | +| `text_records` | object | An object containing key-value pairs of the domain's text records (if included).| +| `coin_types` | object | An object containing key-value pairs of L2 chains and their addresses (if included). | + +## Error Codes + +| Status Code | Description | +| ----------- | ------------------------------------------------- | +| 400 | Bad request. Invalid parameters or network. | +| 500 | Server error. | + +## Curl Example + +``` +curl -X GET \ + -H 'Content-Type: application/json' \ + 'https://namestone.com/api/public_v1/get-domains?admin-address=0x534631Bcf33BDb069fB20A93d2fdb9e4D4dD42CF' +``` + +## Example Response + +```json +[ + { + "domain": "namestone.xyz", + "address": "0x534631Bcf33BDb069fB20A93d2fdb9e4D4dD42CF", + "contenthash": "ipfs://QmUbTVz1L4uEvAPg5QcSu8Rifq2CtTc4SYmasXLAYkFQbp", + "text_records": { + "com.twitter": "namestonehq", + "com.github": "resolverworks", + "url": "https://www.namestone.xyz", + "description": "Namestone ENS Resolver" + }, + "coin_types": { + "60": "0x534631Bcf33BDb069fB20A93d2fdb9e4D4dD42CF", + "2147483785": "0x534631Bcf33BDb069fB20A93d2fdb9e4D4dD42CF" + } + }, + { + "domain": "example.eth", + "address": "0xA47632346786AD59c8590Bd4898D84B4eAB97644", + "contenthash": null, + "text_records": { + "description": "Example domain" + }, + "coin_types": {} + } +] +``` + +This endpoint is particularly useful for domain administrators who need to manage multiple domains and want to retrieve a comprehensive list of all domains under their administration. \ No newline at end of file diff --git a/pages/api/[network]/get-domains.js b/pages/api/[network]/get-domains.js new file mode 100644 index 0000000..a53b627 --- /dev/null +++ b/pages/api/[network]/get-domains.js @@ -0,0 +1,104 @@ +import sql from "../../../lib/db"; +import Cors from "micro-cors"; +import { getNetwork } from "../../../utils/ServerUtils"; + +const cors = Cors({ + allowMethods: ["GET", "HEAD", "POST", "OPTIONS"], + origin: "*", +}); + +async function handler(req, res) { + const network = getNetwork(req); + if (!network) { + return res.status(400).json({ error: "Invalid network" }); + } + + const { headers } = req; + + // Check required parameters + const adminAddress = req.query["admin-address"]; + if (!adminAddress) { + return res.status(400).json({ error: "Missing required admin-address parameter" }); + } + + // get offset and limit + let limit = req.query.limit; + // Check that limit is a number and >= 0 + if (limit && (isNaN(Number(limit)) || Number(limit) < 0)) { + return res.status(400).json({ error: "Invalid limit parameter" }); + } + if (!limit) { + limit = 50; + } + limit = Math.min(limit, 1000); + + let offset = req.query.offset; + // Check that offset is a number and >= 0 + if (offset && (isNaN(Number(offset)) || Number(offset) < 0)) { + return res.status(400).json({ error: "Invalid offset parameter" }); + } + if (!offset) { + offset = 0; + } + + const includeTextRecords = req.query.text_records; + + // Get domains where the provided address is an admin + const domainQuery = await sql` + SELECT domain.id, domain.name, domain.address, domain.contenthash_raw as contenthash + FROM domain + JOIN admin ON admin.domain_id = domain.id + WHERE LOWER(admin.address) = LOWER(${adminAddress}) + AND domain.network = ${network} + ORDER BY domain.name ASC + LIMIT ${limit} OFFSET ${offset}`; + + if (domainQuery.length === 0) { + return res.status(200).json([]); + } + + const domainPayloads = []; + + if (includeTextRecords === "0") { + domainQuery.forEach((entry) => { + const { id, ...domainInfo } = entry; + domainPayloads.push({ + ...domainInfo, + domain: entry.name, + }); + }); + } else { + for (const entry of domainQuery) { + // Get text records from db + const textRecords = await sql` + SELECT * FROM domain_text_record WHERE domain_id = ${entry.id}`; + + const textRecordDict = {}; + textRecords.forEach((record) => { + textRecordDict[record.key] = record.value; + }); + + // get coin types from db + const coinTypes = await sql` + SELECT * FROM domain_coin_type WHERE domain_id = ${entry.id}`; + + const coinTypeDict = {}; + coinTypes.forEach((coin) => { + coinTypeDict[coin.coin_type] = coin.address; + }); + + const domainPayload = { + address: entry.address, + domain: entry.name, + text_records: textRecordDict, + coin_types: coinTypeDict, + contenthash: entry.contenthash, + }; + domainPayloads.push(domainPayload); + } + } + + return res.status(200).json(domainPayloads); +} + +export default cors(handler); \ No newline at end of file diff --git a/pages/api/[network]/get-domains.test.js b/pages/api/[network]/get-domains.test.js new file mode 100644 index 0000000..01902dc --- /dev/null +++ b/pages/api/[network]/get-domains.test.js @@ -0,0 +1,526 @@ +/** + * E2E Tests for /get-domains + * Key features tested: + * - Admin-based access control + * - Network isolation + * - Text record inclusion/exclusion + * - Pagination + */ + +import { createRequest, createResponse } from "node-mocks-http"; +import handler from "./get-domains"; +import { default as sqlForTests } from "../../../test_utils/mock_db"; +import { + setupTestDatabase, + teardownTestDatabase, +} from "../../../test_utils/test_db_setup"; + +const TEST_ADMIN_ADDRESS = "0xAdminAddress123456789012345678901234567890"; +const TEST_NON_ADMIN_ADDRESS = "0xNonAdminAddress123456789012345678901234"; + +const DOMAIN_DATA = { + mainnet: { + name: "test-domain-mainnet.eth", + address: "0xDomainAddress1234567890123456789012345678901", + contenthash: "ipfs://QmTest1234567890", + }, + sepolia: { + name: "test-domain-sepolia.eth", + address: "0xDomainAddress8901234567890123456789012345678", + contenthash: "ipfs://QmTest8901234567", + }, +}; + +const TEXT_RECORDS = [ + { key: "email", value: "test@example.com" }, + { key: "url", value: "https://example.com" }, + { key: "description", value: "Test domain" }, +]; + +const COIN_TYPES = [ + { coin_type: "60", address: "0xEthAddress123456789012345678901234567890" }, + { coin_type: "0", address: "bc1qtest123456789012345678901234" }, + { coin_type: "2147483785", address: "0xMaticAddress12345678901234567890" }, +]; + +const SUPPORTED_NETWORKS = [ + { + path: "public_v1", + name: "mainnet", + domain: DOMAIN_DATA.mainnet, + }, + { + path: "public_v1_sepolia", + name: "sepolia", + domain: DOMAIN_DATA.sepolia, + } +]; + +describe("get-domains API E2E", () => { + let domainIds = { + mainnet: null, + sepolia: null, + }; + + beforeAll(async () => { + await setupTestDatabase(); + + // Set up test data for each network + for (const network of SUPPORTED_NETWORKS) { + // Create domain + const [domain] = await sqlForTests` + INSERT INTO domain ( + name, + network, + address, + contenthash_raw + ) + VALUES ( + ${network.domain.name}, + ${network.name}, + ${network.domain.address}, + ${network.domain.contenthash} + ) + RETURNING id + `; + domainIds[network.name] = domain.id; + + // Add admin + await sqlForTests` + INSERT INTO admin (domain_id, address) + VALUES (${domain.id}, ${TEST_ADMIN_ADDRESS}) + `; + + // Add text records + for (const record of TEXT_RECORDS) { + await sqlForTests` + INSERT INTO domain_text_record (domain_id, key, value) + VALUES (${domain.id}, ${record.key}, ${record.value}) + `; + } + + // Add coin types + for (const coin of COIN_TYPES) { + await sqlForTests` + INSERT INTO domain_coin_type (domain_id, coin_type, address) + VALUES (${domain.id}, ${coin.coin_type}, ${coin.address}) + `; + } + } + }); + + afterAll(async () => { + for (const networkName of Object.keys(domainIds)) { + if (domainIds[networkName]) { + // Delete in correct order to maintain foreign key constraints + await sqlForTests`DELETE FROM domain_coin_type WHERE domain_id = ${domainIds[networkName]}`; + await sqlForTests`DELETE FROM domain_text_record WHERE domain_id = ${domainIds[networkName]}`; + await sqlForTests`DELETE FROM admin WHERE domain_id = ${domainIds[networkName]}`; + await sqlForTests`DELETE FROM domain WHERE id = ${domainIds[networkName]}`; + } + } + await teardownTestDatabase(); + }); + + /** + * Tests network validation: + * - Empty network parameter returns 400 + * - Invalid network value returns 400 + */ + describe("Network Validation", () => { + test("getDomains_noNetworkSupplied_returns400", async () => { + const req = createRequest({ + method: "GET", + query: { + network: "", + "admin-address": TEST_ADMIN_ADDRESS, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Invalid network", + }); + }); + + test("getDomains_invalidNetwork_returns400", async () => { + const req = createRequest({ + method: "GET", + query: { + network: "invalid_network", + "admin-address": TEST_ADMIN_ADDRESS, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Invalid network", + }); + }); + }); + + /** + * Tests admin address validation: + * - Missing admin-address parameter returns 400 + */ + describe("Admin Address Validation", () => { + test("getDomains_missingAdminAddress_returns400", async () => { + const req = createRequest({ + method: "GET", + query: { + network: SUPPORTED_NETWORKS[0].path, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Missing required admin-address parameter", + }); + }); + }); + + describe.each(SUPPORTED_NETWORKS)("get-domains API E2E for %s", (networkConfig) => { + /** + * Tests retrieving domains for admin address: + * - Admin address returns associated domains + * - Non-admin address returns empty array + * - Address case-insensitivity + */ + describe("Admin Access Control", () => { + test("returns domains for admin address", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData).toHaveLength(1); + expect(responseData[0].domain).toBe(networkConfig.domain.name); + expect(responseData[0].address).toBe(networkConfig.domain.address); + expect(responseData[0].contenthash).toBe(networkConfig.domain.contenthash); + + // Check text records + expect(responseData[0].text_records).toBeDefined(); + TEXT_RECORDS.forEach(record => { + expect(responseData[0].text_records[record.key]).toBe(record.value); + }); + + // Check coin types + expect(responseData[0].coin_types).toBeDefined(); + COIN_TYPES.forEach(coin => { + expect(responseData[0].coin_types[coin.coin_type]).toBe(coin.address); + }); + }); + + test("returns empty array for non-admin address", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_NON_ADMIN_ADDRESS, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData).toHaveLength(0); + }); + + test("address check is case-insensitive", async () => { + const lowerCaseAddress = TEST_ADMIN_ADDRESS.toLowerCase(); + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": lowerCaseAddress, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData).toHaveLength(1); + expect(responseData[0].domain).toBe(networkConfig.domain.name); + }); + }); + + /** + * Tests text records inclusion/exclusion: + * - Default inclusion of text records + * - Exclusion with text_records=0 + */ + describe("Text Records and Coin Types", () => { + test("includes text records and coin types by default", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData[0].text_records).toBeDefined(); + expect(responseData[0].coin_types).toBeDefined(); + }); + + test("excludes text records and coin types when text_records=0", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + text_records: "0", + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData[0].text_records).toBeUndefined(); + expect(responseData[0].coin_types).toBeUndefined(); + }); + }); + + /** + * Tests pagination functionality: + * - Limit parameter validation + * - Offset parameter validation + */ + describe("Pagination", () => { + // Set up additional domains for pagination tests + beforeAll(async () => { + for (let i = 1; i <= 3; i++) { + const [domain] = await sqlForTests` + INSERT INTO domain ( + name, + network, + address + ) + VALUES ( + ${`test-domain-${networkConfig.name}-${i}.eth`}, + ${networkConfig.name}, + ${`0xAddress${i}${networkConfig.name}`} + ) + RETURNING id + `; + + await sqlForTests` + INSERT INTO admin (domain_id, address) + VALUES (${domain.id}, ${TEST_ADMIN_ADDRESS}) + `; + } + }); + + afterAll(async () => { + await sqlForTests` + DELETE FROM admin WHERE domain_id IN ( + SELECT id FROM domain + WHERE name LIKE ${'test-domain-' + networkConfig.name + '-%'} + ) + `; + await sqlForTests` + DELETE FROM domain + WHERE name LIKE ${'test-domain-' + networkConfig.name + '-%'} + `; + }); + + test("respects limit parameter", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + limit: 2, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData).toHaveLength(2); + }); + + test("returns 400 for non-numeric limit", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + limit: "not-a-number", + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Invalid limit parameter", + }); + }); + + test("returns 400 for negative limit", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + limit: -5, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Invalid limit parameter", + }); + }); + + test("respects offset parameter", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + offset: 1, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData.length).toBeGreaterThanOrEqual(3); + }); + + test("returns 400 for non-numeric offset", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + offset: "not-a-number", + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Invalid offset parameter", + }); + }); + + test("returns 400 for negative offset", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + offset: -10, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(400); + expect(JSON.parse(response._getData())).toEqual({ + error: "Invalid offset parameter", + }); + }); + + test("respects both limit and offset parameters", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + limit: 2, + offset: 1, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + expect(responseData).toHaveLength(2); + }); + }); + + /** + * Tests that the endpoint properly isolates data by network + */ + describe("Network Isolation", () => { + // Create a cross-network admin to test network isolation + beforeAll(async () => { + // Add the same admin to a domain in another network for testing + const otherNetwork = SUPPORTED_NETWORKS.find(n => n.name !== networkConfig.name); + if (otherNetwork) { + await sqlForTests` + INSERT INTO admin (domain_id, address) + VALUES (${domainIds[otherNetwork.name]}, ${TEST_ADMIN_ADDRESS}) + `; + } + }); + + test("only returns domains from the specified network", async () => { + const req = createRequest({ + method: "GET", + query: { + network: networkConfig.path, + "admin-address": TEST_ADMIN_ADDRESS, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + const responseData = JSON.parse(response._getData()); + + // All returned domains should be from the requested network + responseData.forEach(domain => { + // Check if the domain name matches the pattern for the current network + expect(domain.domain.includes(networkConfig.name)).toBe(true); + }); + }); + }); + }); +}); \ No newline at end of file From 99e8f9d5882eaff38413aa5b662a1951c64bc8f2 Mon Sep 17 00:00:00 2001 From: Darian Bailey Date: Tue, 20 May 2025 19:35:48 -0400 Subject: [PATCH 2/2] fix tests --- pages/api/[network]/get-domains.test.js | 35 +++++++++---------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/pages/api/[network]/get-domains.test.js b/pages/api/[network]/get-domains.test.js index 01902dc..3df5da9 100644 --- a/pages/api/[network]/get-domains.test.js +++ b/pages/api/[network]/get-domains.test.js @@ -316,6 +316,8 @@ describe("get-domains API E2E", () => { */ describe("Pagination", () => { // Set up additional domains for pagination tests + let additionalDomainIds = []; + beforeAll(async () => { for (let i = 1; i <= 3; i++) { const [domain] = await sqlForTests` @@ -332,6 +334,8 @@ describe("get-domains API E2E", () => { RETURNING id `; + additionalDomainIds.push(domain.id); + await sqlForTests` INSERT INTO admin (domain_id, address) VALUES (${domain.id}, ${TEST_ADMIN_ADDRESS}) @@ -340,16 +344,11 @@ describe("get-domains API E2E", () => { }); afterAll(async () => { - await sqlForTests` - DELETE FROM admin WHERE domain_id IN ( - SELECT id FROM domain - WHERE name LIKE ${'test-domain-' + networkConfig.name + '-%'} - ) - `; - await sqlForTests` - DELETE FROM domain - WHERE name LIKE ${'test-domain-' + networkConfig.name + '-%'} - `; + for (const domainId of additionalDomainIds) { + await sqlForTests`DELETE FROM admin WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM domain WHERE id = ${domainId}`; + } + additionalDomainIds = []; }); test("respects limit parameter", async () => { @@ -423,7 +422,8 @@ describe("get-domains API E2E", () => { expect(response._getStatusCode()).toBe(200); const responseData = JSON.parse(response._getData()); - expect(responseData.length).toBeGreaterThanOrEqual(3); + // With 1 original domain + 3 added domains, offset 1 should return 3 domains + expect(responseData.length).toBe(3); }); test("returns 400 for non-numeric offset", async () => { @@ -488,18 +488,7 @@ describe("get-domains API E2E", () => { * Tests that the endpoint properly isolates data by network */ describe("Network Isolation", () => { - // Create a cross-network admin to test network isolation - beforeAll(async () => { - // Add the same admin to a domain in another network for testing - const otherNetwork = SUPPORTED_NETWORKS.find(n => n.name !== networkConfig.name); - if (otherNetwork) { - await sqlForTests` - INSERT INTO admin (domain_id, address) - VALUES (${domainIds[otherNetwork.name]}, ${TEST_ADMIN_ADDRESS}) - `; - } - }); - + // This test relies on the data created in the main beforeAll test("only returns domains from the specified network", async () => { const req = createRequest({ method: "GET",