Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions apps/web/src/app/api/__tests__/regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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

Expand Down Expand Up @@ -140,16 +142,29 @@ function mockReadingDb(meter: unknown) {
} as ReturnType<typeof createServiceClient>)
}

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 }),
Expand All @@ -158,7 +173,10 @@ function mockMeterDb({ existing = null }: { existing?: unknown } = {}) {
} as ReturnType<typeof createServiceClient>)
}

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 ────────────────────────────

Expand Down Expand Up @@ -220,38 +238,37 @@ 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),
})
)
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 () => {
Expand Down
36 changes: 29 additions & 7 deletions apps/web/src/app/api/meters/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<typeof createServiceClient>)
Expand All @@ -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 }),
Expand All @@ -56,7 +69,10 @@ function mockDb({ existing = null, insertData = { id: 'meter-1', ...VALID_BODY,
} as ReturnType<typeof createServiceClient>)
}

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 () => {
Expand All @@ -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' }))
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/certificates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default function CertificatesPage() {
const { toast, dismiss } = useToast()
const { address, connected } = useWallet()
const [retiring, setRetiring] = useState<Certificate | null>(null)
const [transferring, setTransferring] = useState<Certificate | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkRetiring, setBulkRetiring] = useState(false)

Expand Down
Loading
Loading