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/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 {