From 5c93456c060b902d3f5c548ad73052e3e54be49f Mon Sep 17 00:00:00 2001 From: Darian Bailey Date: Tue, 20 May 2025 19:21:09 -0400 Subject: [PATCH 1/4] 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/4] 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", From a77b13c8123fd9ef386027e27134637857b5b3ea Mon Sep 17 00:00:00 2001 From: Darian Bailey Date: Tue, 8 Jul 2025 16:01:52 -0400 Subject: [PATCH 3/4] add/delete admin --- jest.env.js | 2 + pages/api/[network]/add-admin.js | 88 +++++ pages/api/[network]/add-admin.test.js | 131 ++++++++ pages/api/[network]/delete-admin.js | 90 +++++ pages/api/[network]/delete-admin.test.js | 138 ++++++++ pages/api/admin/get-domains.js | 98 ++++++ pages/api/admin/get-domains.test.js | 403 +++++++++++++++++++++++ prisma/schema.prisma | 2 + 8 files changed, 952 insertions(+) create mode 100644 jest.env.js create mode 100644 pages/api/[network]/add-admin.js create mode 100644 pages/api/[network]/add-admin.test.js create mode 100644 pages/api/[network]/delete-admin.js create mode 100644 pages/api/[network]/delete-admin.test.js create mode 100644 pages/api/admin/get-domains.js create mode 100644 pages/api/admin/get-domains.test.js diff --git a/jest.env.js b/jest.env.js new file mode 100644 index 0000000..1d2989f --- /dev/null +++ b/jest.env.js @@ -0,0 +1,2 @@ +import dotenv from "dotenv"; +dotenv.config({ path: ".env.test" }); diff --git a/pages/api/[network]/add-admin.js b/pages/api/[network]/add-admin.js new file mode 100644 index 0000000..dcf6b47 --- /dev/null +++ b/pages/api/[network]/add-admin.js @@ -0,0 +1,88 @@ +import sql from "../../../lib/db"; +import { + checkApiKey, + getNetwork, + getClientIp, +} from "../../../utils/ServerUtils"; +import Cors from "micro-cors"; +import { normalize } from "viem/ens"; + +const cors = Cors({ + allowMethods: ["GET", "HEAD", "POST"], + 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 + let body = req.body; + if (typeof body === "string") { + body = JSON.parse(body); + } + if (!body.domain) { + return res.status(400).json({ error: "Missing domain" }); + } + if (!body.admin_address) { + return res.status(400).json({ error: "Missing admin_address" }); + } + + let domain; + let admin_address = body.admin_address; + try { + domain = normalize(body.domain); + } catch (e) { + return res.status(400).json({ error: "Invalid ens domain" }); + } + + // Check API key + const allowedApi = await checkApiKey( + headers.authorization || req.query.api_key, + domain + ); + if (!allowedApi) { + return res + .status(401) + .json({ error: "You are not authorized to use this endpoint" }); + } + + try { + // Check if domain exists + const domainQuery = await sql` + select id from domain where name = ${domain} and network = ${network} limit 1`; + + if (domainQuery.length === 0) { + return res.status(400).json({ error: "Domain does not exist" }); + } + + const domainId = domainQuery[0].id; + + // Insert admin to admin table + await sql` + insert into admin (domain_id, address) + values (${domainId}, ${admin_address}) + on conflict (domain_id, address) do nothing; + `; + } catch (error) { + console.error("Error adding admin:", error); + return res.status(500).json({ error: "Internal server error" }); + } + + // log user engagement + const clientIp = getClientIp(req); + const jsonPayload = JSON.stringify({ + body: body, + ip_address: clientIp, + }); + await sql` + insert into user_engagement (address, name, details) + values (${admin_address},'add_admin', ${jsonPayload})`; + + return res.status(200).json({ success: true }); +} + +export default cors(handler); diff --git a/pages/api/[network]/add-admin.test.js b/pages/api/[network]/add-admin.test.js new file mode 100644 index 0000000..15bd074 --- /dev/null +++ b/pages/api/[network]/add-admin.test.js @@ -0,0 +1,131 @@ +import { createRequest, createResponse } from "node-mocks-http"; +import handler from "./add-admin"; +import sqlForTests from "../../../test_utils/mock_db"; +import { + setupTestDatabase, + teardownTestDatabase, +} from "../../../test_utils/test_db_setup"; + +const TEST_DOMAIN = "test-admin.eth"; +const TEST_ADMIN_ADDRESS = "0xAdminTestAddress123456789012345678901234"; +const TEST_API_KEY = "fake-test-api-key"; +const TEST_NETWORK = "mainnet"; + +describe("add-admin API E2E", () => { + let domainId; + + beforeAll(async () => { + process.env.TEST_API_KEY = TEST_API_KEY; + await setupTestDatabase(); + // Insert domain + const [domain] = await sqlForTests` + INSERT INTO domain (name, network) VALUES (${TEST_DOMAIN}, ${TEST_NETWORK}) RETURNING id + `; + domainId = domain.id; + // Insert API key + await sqlForTests` + INSERT INTO api_key (domain_id, key) VALUES (${domainId}, ${TEST_API_KEY}) + `; + }); + + afterAll(async () => { + await sqlForTests`DELETE FROM admin WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM api_key WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM domain WHERE id = ${domainId}`; + await teardownTestDatabase(); + }); + + test("successfully adds an admin", async () => { + try { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: TEST_DOMAIN, admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(200); + expect(JSON.parse(res._getData())).toEqual({ success: true }); + // Check DB + const admins = + await sqlForTests`SELECT * FROM admin WHERE domain_id = ${domainId} AND address = ${TEST_ADMIN_ADDRESS}`; + expect(admins.length).toBe(1); + } catch (err) { + console.error("Test error (successfully adds an admin):", err); + throw err; + } + }); + + test("missing domain returns 400", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ error: "Missing domain" }); + }); + + test("missing admin_address returns 400", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: TEST_DOMAIN }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + error: "Missing admin_address", + }); + }); + + test("invalid domain returns 400", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: "invalid domain!@#", admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ error: "Invalid ens domain" }); + }); + + test("unauthorized API key returns 401", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: "wrong-key" }, + body: { domain: TEST_DOMAIN, admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(401); + expect(JSON.parse(res._getData())).toEqual({ + error: "You are not authorized to use this endpoint", + }); + }); + + test("duplicate admin does not error", async () => { + // Add once + await sqlForTests`INSERT INTO admin (domain_id, address) VALUES (${domainId}, ${TEST_ADMIN_ADDRESS}) ON CONFLICT DO NOTHING`; + // Add again via API + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: TEST_DOMAIN, admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(200); + expect(JSON.parse(res._getData())).toEqual({ success: true }); + }); +}); diff --git a/pages/api/[network]/delete-admin.js b/pages/api/[network]/delete-admin.js new file mode 100644 index 0000000..02186e9 --- /dev/null +++ b/pages/api/[network]/delete-admin.js @@ -0,0 +1,90 @@ +import sql from "../../../lib/db"; +import { + checkApiKey, + getNetwork, + getClientIp, +} from "../../../utils/ServerUtils"; +import Cors from "micro-cors"; +import { normalize } from "viem/ens"; + +const cors = Cors({ + allowMethods: ["GET", "HEAD", "POST"], + 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 + let body = req.body; + if (typeof body === "string") { + body = JSON.parse(body); + } + if (!body.domain) { + return res.status(400).json({ error: "Missing domain" }); + } + if (!body.admin_address) { + return res.status(400).json({ error: "Missing admin_address" }); + } + + let domain; + let admin_address = body.admin_address; + try { + domain = normalize(body.domain); + } catch (e) { + return res.status(400).json({ error: "Invalid ens domain" }); + } + + // Check API key + const allowedApi = await checkApiKey( + headers.authorization || req.query.api_key, + domain + ); + if (!allowedApi) { + return res + .status(401) + .json({ error: "You are not authorized to use this endpoint" }); + } + + try { + // Check if domain exists + const domainQuery = await sql` + select id from domain where name = ${domain} and network = ${network} limit 1`; + + if (domainQuery.length === 0) { + return res.status(400).json({ error: "Domain does not exist" }); + } + + const domainId = domainQuery[0].id; + + // Delete admin from admin table + const result = await sql` + delete from admin where domain_id = ${domainId} and address = ${admin_address}; + `; + + if (result.count === 0) { + return res.status(404).json({ error: "Admin not found for this domain" }); + } + } catch (error) { + console.error("Error deleting admin:", error); + return res.status(500).json({ error: "Internal server error" }); + } + + // log user engagement + const clientIp = getClientIp(req); + const jsonPayload = JSON.stringify({ + body: body, + ip_address: clientIp, + }); + await sql` + insert into user_engagement (address, name, details) + values (${admin_address},'delete_admin', ${jsonPayload})`; + + return res.status(200).json({ success: true }); +} + +export default cors(handler); diff --git a/pages/api/[network]/delete-admin.test.js b/pages/api/[network]/delete-admin.test.js new file mode 100644 index 0000000..86be1be --- /dev/null +++ b/pages/api/[network]/delete-admin.test.js @@ -0,0 +1,138 @@ +import { createRequest, createResponse } from "node-mocks-http"; +import handler from "./delete-admin"; +import sqlForTests from "../../../test_utils/mock_db"; +import { + setupTestDatabase, + teardownTestDatabase, +} from "../../../test_utils/test_db_setup"; + +const TEST_DOMAIN = "test-delete-admin.eth"; +const TEST_ADMIN_ADDRESS = "0xDeleteAdminTestAddress12345678901234567890"; +const TEST_API_KEY = "fake-test-api-key"; +const TEST_NETWORK = "mainnet"; + +describe("delete-admin API E2E", () => { + let domainId; + + beforeAll(async () => { + process.env.TEST_API_KEY = TEST_API_KEY; + await setupTestDatabase(); + // Insert domain + const [domain] = await sqlForTests` + INSERT INTO domain (name, network) VALUES (${TEST_DOMAIN}, ${TEST_NETWORK}) RETURNING id + `; + domainId = domain.id; + // Insert API key + await sqlForTests` + INSERT INTO api_key (domain_id, key) VALUES (${domainId}, ${TEST_API_KEY}) + `; + // Insert admin + await sqlForTests` + INSERT INTO admin (domain_id, address) VALUES (${domainId}, ${TEST_ADMIN_ADDRESS}) + `; + }); + + afterAll(async () => { + await sqlForTests`DELETE FROM admin WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM api_key WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM domain WHERE id = ${domainId}`; + await teardownTestDatabase(); + }); + + test("successfully deletes an admin", async () => { + try { + // Ensure admin exists + await sqlForTests`INSERT INTO admin (domain_id, address) VALUES (${domainId}, ${TEST_ADMIN_ADDRESS}) ON CONFLICT DO NOTHING`; + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: TEST_DOMAIN, admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(200); + expect(JSON.parse(res._getData())).toEqual({ success: true }); + // Check DB + const admins = + await sqlForTests`SELECT * FROM admin WHERE domain_id = ${domainId} AND address = ${TEST_ADMIN_ADDRESS}`; + expect(admins.length).toBe(0); + } catch (err) { + console.error("Test error (successfully deletes an admin):", err); + throw err; + } + }); + + test("missing domain returns 400", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ error: "Missing domain" }); + }); + + test("missing admin_address returns 400", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: TEST_DOMAIN }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + error: "Missing admin_address", + }); + }); + + test("invalid domain returns 400", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: "invalid domain!@#", admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ error: "Invalid ens domain" }); + }); + + test("unauthorized API key returns 401", async () => { + const req = createRequest({ + method: "POST", + headers: { authorization: "wrong-key" }, + body: { domain: TEST_DOMAIN, admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(401); + expect(JSON.parse(res._getData())).toEqual({ + error: "You are not authorized to use this endpoint", + }); + }); + + test("deleting non-existent admin returns 404", async () => { + // Ensure admin does not exist + await sqlForTests`DELETE FROM admin WHERE domain_id = ${domainId} AND address = ${TEST_ADMIN_ADDRESS}`; + const req = createRequest({ + method: "POST", + headers: { authorization: TEST_API_KEY }, + body: { domain: TEST_DOMAIN, admin_address: TEST_ADMIN_ADDRESS }, + query: { network: "public_v1" }, + }); + const res = createResponse(); + await handler(req, res); + expect(res._getStatusCode()).toBe(404); + expect(JSON.parse(res._getData())).toEqual({ + error: "Admin not found for this domain", + }); + }); +}); diff --git a/pages/api/admin/get-domains.js b/pages/api/admin/get-domains.js new file mode 100644 index 0000000..5af3d82 --- /dev/null +++ b/pages/api/admin/get-domains.js @@ -0,0 +1,98 @@ +import sql from "../../../lib/db"; +import Cors from "micro-cors"; + +const cors = Cors({ + allowMethods: ["GET", "HEAD", "POST", "OPTIONS"], + origin: "*", +}); + +async function handler(req, res) { + // 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, domain.network + FROM domain + JOIN admin ON admin.domain_id = domain.id + WHERE LOWER(admin.address) = LOWER(${adminAddress}) + 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, + network: entry.network, + }; + domainPayloads.push(domainPayload); + } + } + + return res.status(200).json(domainPayloads); +} + +export default cors(handler); diff --git a/pages/api/admin/get-domains.test.js b/pages/api/admin/get-domains.test.js new file mode 100644 index 0000000..52ab6ee --- /dev/null +++ b/pages/api/admin/get-domains.test.js @@ -0,0 +1,403 @@ +/** + * 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" }, +]; + + + +describe("get-domains API E2E", () => { + let domainId = null; + + beforeAll(async () => { + await setupTestDatabase(); + + // Set up test data + const [domain] = await sqlForTests` + INSERT INTO domain ( + name, + network, + address, + contenthash_raw + ) + VALUES ( + ${DOMAIN_DATA.mainnet.name}, + 'mainnet', + ${DOMAIN_DATA.mainnet.address}, + ${DOMAIN_DATA.mainnet.contenthash} + ) + RETURNING id + `; + domainId = domain.id; + + // Add admin + await sqlForTests` + INSERT INTO admin (domain_id, address) + VALUES (${domainId}, ${TEST_ADMIN_ADDRESS}) + `; + + // Add text records + for (const record of TEXT_RECORDS) { + await sqlForTests` + INSERT INTO domain_text_record (domain_id, key, value) + VALUES (${domainId}, ${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 (${domainId}, ${coin.coin_type}, ${coin.address}) + `; + } + }); + + afterAll(async () => { + if (domainId) { + // Delete in correct order to maintain foreign key constraints + await sqlForTests`DELETE FROM domain_coin_type WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM domain_text_record WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM admin WHERE domain_id = ${domainId}`; + await sqlForTests`DELETE FROM domain WHERE id = ${domainId}`; + } + await teardownTestDatabase(); + }); + + /** + * 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: {}, + }); + 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("Admin Access Control", () => { + test("returns domains for admin address", async () => { + const req = createRequest({ + method: "GET", + query: { + "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(DOMAIN_DATA.mainnet.name); + expect(responseData[0].address).toBe(DOMAIN_DATA.mainnet.address); + expect(responseData[0].contenthash).toBe(DOMAIN_DATA.mainnet.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: { + "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: { + "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(DOMAIN_DATA.mainnet.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: { + "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: { + "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 + let additionalDomainIds = []; + + beforeAll(async () => { + for (let i = 1; i <= 3; i++) { + const [domain] = await sqlForTests` + INSERT INTO domain ( + name, + network, + address + ) + VALUES ( + ${`test-domain-mainnet-${i}.eth`}, + 'mainnet', + ${`0xAddress${i}mainnet`} + ) + RETURNING id + `; + + additionalDomainIds.push(domain.id); + + await sqlForTests` + INSERT INTO admin (domain_id, address) + VALUES (${domain.id}, ${TEST_ADMIN_ADDRESS}) + `; + } + }); + + afterAll(async () => { + 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 () => { + const req = createRequest({ + method: "GET", + query: { + "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: { + "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: { + "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: { + "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()); + // 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 () => { + const req = createRequest({ + method: "GET", + query: { + "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: { + "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: { + "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); + }); + }); +}); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47b7301..9854076 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -98,6 +98,8 @@ model admin { domain_id Int domain domain @relation(fields: [domain_id], references: [id]) address String? + + @@unique([domain_id, address]) } model super_admin { From db7c697a9299246be647500a07af811123d2b042 Mon Sep 17 00:00:00 2001 From: Darian Bailey Date: Tue, 8 Jul 2025 16:17:31 -0400 Subject: [PATCH 4/4] remove get domains from network --- pages/api/[network]/get-domains.js | 104 ----- pages/api/[network]/get-domains.test.js | 515 ------------------------ 2 files changed, 619 deletions(-) delete mode 100644 pages/api/[network]/get-domains.js delete mode 100644 pages/api/[network]/get-domains.test.js diff --git a/pages/api/[network]/get-domains.js b/pages/api/[network]/get-domains.js deleted file mode 100644 index a53b627..0000000 --- a/pages/api/[network]/get-domains.js +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 3df5da9..0000000 --- a/pages/api/[network]/get-domains.test.js +++ /dev/null @@ -1,515 +0,0 @@ -/** - * 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 - let additionalDomainIds = []; - - 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 - `; - - additionalDomainIds.push(domain.id); - - await sqlForTests` - INSERT INTO admin (domain_id, address) - VALUES (${domain.id}, ${TEST_ADMIN_ADDRESS}) - `; - } - }); - - afterAll(async () => { - 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 () => { - 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()); - // 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 () => { - 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", () => { - // 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", - 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