diff --git a/apps/web/src/app/api/__tests__/regression.test.ts b/apps/web/src/app/api/__tests__/regression.test.ts index f94171c..302b857 100644 --- a/apps/web/src/app/api/__tests__/regression.test.ts +++ b/apps/web/src/app/api/__tests__/regression.test.ts @@ -27,7 +27,7 @@ vi.mock('@/lib/cache', () => ({ checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), })) vi.mock('@/lib/auth', () => ({ - requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' } }), + requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: 'coop-1' }), isAuthError: vi.fn().mockReturnValue(false), })) vi.mock('@/lib/webhooks', () => ({ @@ -41,10 +41,12 @@ import { createServiceClient } from '@/lib/supabase' import { POST as postReading } from '@/app/api/readings/route' import { POST as postMeter } from '@/app/api/meters/route' import { mintCertificates } from '@/lib/stellar' +import { requireAuth } from '@/lib/auth' // ── Helpers ─────────────────────────────────────────────────────────────────── const METER_ID = '123e4567-e89b-12d3-a456-426614174000' +const COOP_ID = 'coop-1' const KWH = 5.0 const TIMESTAMP = 1_700_000_000 @@ -140,16 +142,29 @@ function mockReadingDb(meter: unknown) { } as ReturnType) } -function mockMeterDb({ existing = null }: { existing?: unknown } = {}) { +function mockMeterDb({ + existing = null, + accountType = 'cooperative', + meterCount = 0, +}: { existing?: unknown; accountType?: string; meterCount?: number } = {}) { const maybeSingle = vi.fn().mockResolvedValue({ data: existing }) const insertSingle = vi.fn().mockResolvedValue({ data: { id: 'meter-1', active: true }, error: null, }) + vi.mocked(createServiceClient).mockReturnValue({ from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ maybeSingle }), + select: vi.fn().mockImplementation((_fields, options) => { + if (options?.count) { + return Promise.resolve({ count: meterCount, error: null }) + } + return { + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: existing }), + single: vi.fn().mockResolvedValue({ data: { account_type: accountType }, error: null }), + }), + } }), insert: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: insertSingle }), @@ -158,7 +173,10 @@ function mockMeterDb({ existing = null }: { existing?: unknown } = {}) { } as ReturnType) } -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: COOP_ID, accessToken: 'abc' }) +}) // ── Issue #29 — Input validation on all API routes ──────────────────────────── @@ -220,7 +238,6 @@ describe('regression issue_29: input validation on API routes', () => { mockMeterDb() const res = await postMeter( makeMeterRequest({ - cooperative_id: '123e4567-e89b-12d3-a456-426614174000', serial_number: 'SN-001', pubkey_hex: 'a'.repeat(64), }) @@ -228,30 +245,30 @@ describe('regression issue_29: input validation on API routes', () => { expect(res.status).toBe(400) }) - it('test_issue_29_meters_rejects_invalid_cooperative_id', async () => { + it('test_issue_29_meters_rejects_wrong_length_pubkey', async () => { mockMeterDb() const res = await postMeter( makeMeterRequest({ name: 'Panel A', - cooperative_id: 'not-a-uuid', serial_number: 'SN-001', - pubkey_hex: 'a'.repeat(64), + pubkey_hex: 'tooshort', }) ) expect(res.status).toBe(400) }) - it('test_issue_29_meters_rejects_wrong_length_pubkey', async () => { - mockMeterDb() + it('test_issue_351_meters_enforces_individual_limit', async () => { + mockMeterDb({ accountType: 'individual', meterCount: 1 }) const res = await postMeter( makeMeterRequest({ - name: 'Panel A', - cooperative_id: '123e4567-e89b-12d3-a456-426614174000', - serial_number: 'SN-001', - pubkey_hex: 'tooshort', + name: 'Second Meter', + serial_number: 'SN-002', + pubkey_hex: 'b'.repeat(64), }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(403) + const json = await res.json() + expect(json.error).toMatch(/limited to 1 meter/i) }) it('test_issue_29_validation_runs_before_db_access', async () => { diff --git a/apps/web/src/app/api/meters/route.test.ts b/apps/web/src/app/api/meters/route.test.ts index c8b74b3..e96797c 100644 --- a/apps/web/src/app/api/meters/route.test.ts +++ b/apps/web/src/app/api/meters/route.test.ts @@ -2,12 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/supabase', () => ({ createServiceClient: vi.fn() })) vi.mock('@/lib/auth', () => ({ - requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, accessToken: 'tok' }), + requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: 'coop-1', accessToken: 'tok' }), isAuthError: vi.fn().mockReturnValue(false), })) import { createServiceClient } from '@/lib/supabase' import { GET, POST } from '@/app/api/meters/route' +import { requireAuth } from '@/lib/auth' function makeGetRequest() { return { @@ -20,7 +21,9 @@ function mockDbGet(data: unknown[], error: unknown = null) { vi.mocked(createServiceClient).mockReturnValue({ from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ - order: vi.fn().mockResolvedValue({ data, error }), + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ data, error }), + }), }), }), } as ReturnType) @@ -35,19 +38,29 @@ function makeRequest(body: unknown) { const VALID_BODY = { name: 'Solar Panel A', - cooperative_id: '123e4567-e89b-12d3-a456-426614174000', serial_number: 'SN-001', pubkey_hex: 'a'.repeat(64), } -function mockDb({ existing = null, insertData = { id: 'meter-1', ...VALID_BODY, active: true } } = {}) { +function mockDb({ + existing = null, + accountType = 'cooperative', + meterCount = 0, + insertData = { id: 'meter-1', ...VALID_BODY, active: true } +} = {}) { const maybeSingle = vi.fn().mockResolvedValue({ data: existing }) const insertSingle = vi.fn().mockResolvedValue({ data: insertData, error: null }) vi.mocked(createServiceClient).mockReturnValue({ from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ maybeSingle }), + select: vi.fn().mockImplementation((_fields, options) => { + if (options?.count) return Promise.resolve({ count: meterCount, error: null }) + return { + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: existing }), + single: vi.fn().mockResolvedValue({ data: { account_type: accountType }, error: null }), + }), + } }), insert: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: insertSingle }), @@ -56,7 +69,10 @@ function mockDb({ existing = null, insertData = { id: 'meter-1', ...VALID_BODY, } as ReturnType) } -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: 'coop-1', accessToken: 'tok' }) +}) describe('POST /api/meters', () => { it('returns 400 when name is missing', async () => { @@ -80,6 +96,12 @@ describe('POST /api/meters', () => { expect(res.status).toBe(201) }) + it('returns 403 when individual account limit is reached', async () => { + mockDb({ accountType: 'individual', meterCount: 1 }) + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(403) + }) + it('returns 400 when pubkey_hex is wrong length', async () => { mockDb() const res = await POST(makeRequest({ ...VALID_BODY, pubkey_hex: 'short' })) diff --git a/apps/web/src/app/certificates/page.tsx b/apps/web/src/app/certificates/page.tsx index 7960dc5..bc38a42 100644 --- a/apps/web/src/app/certificates/page.tsx +++ b/apps/web/src/app/certificates/page.tsx @@ -43,6 +43,7 @@ export default function CertificatesPage() { const { toast, dismiss } = useToast() const { address, connected } = useWallet() const [retiring, setRetiring] = useState(null) + const [transferring, setTransferring] = useState(null) const [selected, setSelected] = useState>(new Set()) const [bulkRetiring, setBulkRetiring] = useState(false) diff --git a/apps/web/src/app/governance/page.tsx b/apps/web/src/app/governance/page.tsx index 231ae1c..2345193 100644 --- a/apps/web/src/app/governance/page.tsx +++ b/apps/web/src/app/governance/page.tsx @@ -3,11 +3,12 @@ import { useState } from 'react' import { Vote, Plus, Clock, CheckCircle, XCircle, Minus, ChevronDown, ChevronUp } from 'lucide-react' import { useWallet } from '@/hooks/useWallet' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // ── Types ────────────────────────────────────────────────────────────────────── type VoteChoice = 'for' | 'against' | 'abstain' -type ProposalStatus = 'active' | 'passed' | 'rejected' | 'pending' +type ProposalStatus = 'active' | 'passed' | 'rejected' | 'pending' | 'expired' interface Tally { for: number; against: number; abstain: number } @@ -17,45 +18,55 @@ interface Proposal { description: string status: ProposalStatus tally: Tally - endsAt: Date + ends_at: string userVote?: VoteChoice } -// ── Seed data (replaced by real contract calls in production) ────────────────── - -const SEED: Proposal[] = [ - { - id: 'prop-001', - title: 'Increase minimum meter reading interval to 15 minutes', - description: 'Reduce on-chain anchoring costs by batching readings every 15 minutes instead of every 5.', - status: 'active', - tally: { for: 142, against: 38, abstain: 12 }, - endsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - }, - { - id: 'prop-002', - title: 'Add support for wind energy certificates', - description: 'Extend the energy_token contract to support wind generation alongside solar.', - status: 'active', - tally: { for: 89, against: 61, abstain: 5 }, - endsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - { - id: 'prop-003', - title: 'Integrate I-REC bridge (v1)', - description: 'Build a bridge to the I-REC registry so SolarProof certificates can be cross-listed.', - status: 'passed', - tally: { for: 210, against: 30, abstain: 8 }, - endsAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), - }, -] +// ── Fetchers ────────────────────────────────────────────────────────────────── + +async function fetchProposals(): Promise { + const res = await fetch('/api/governance') + if (!res.ok) throw new Error('Failed to load proposals') + return res.json() +} + +async function createProposal(body: { + title: string + description: string + action: string + days: number +}): Promise { + const res = await fetch('/api/governance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error ?? 'Failed to create proposal') + } + return res.json() +} + +async function castVote(proposalId: string, choice: VoteChoice): Promise { + const res = await fetch(`/api/governance/${proposalId}/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ choice }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error ?? 'Failed to cast vote') + } +} // ── Helpers ──────────────────────────────────────────────────────────────────── function totalVotes(t: Tally) { return t.for + t.against + t.abstain } function pct(n: number, total: number) { return total === 0 ? 0 : Math.round((n / total) * 100) } -function countdown(endsAt: Date): string { +function countdown(endsAtStr: string): string { + const endsAt = new Date(endsAtStr) const diff = endsAt.getTime() - Date.now() if (diff <= 0) return 'Ended' const d = Math.floor(diff / 86_400_000) @@ -68,6 +79,7 @@ const STATUS_BADGE: Record = { passed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', pending: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + expired: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', } // ── Sub-components ───────────────────────────────────────────────────────────── @@ -143,7 +155,7 @@ function ProposalCard({ walletConnected: boolean }) { const [expanded, setExpanded] = useState(false) - const isActive = proposal.status === 'active' + const isActive = proposal.status === 'active' && new Date(proposal.ends_at) > new Date() return (