Files
accounted/lib/company/__tests__/create-company-team-authorization.pg.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

127 lines
4.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser } from '@/tests/pg/fixtures'
// Authorization tests for the team_id branch of create_company_with_owner
// added in 20260519180000_enforce_team_membership_in_create_company.sql.
// The RPC is SECURITY DEFINER and bypasses RLS on the companies INSERT, so
// without the in-body membership check any authenticated user could attach a
// freshly-created company to an arbitrary team_id (OWASP ASVS V8.2.1).
//
// These tests prove the check fires for non-members and passes for members
// (both owners and ordinary members), while preserving the NULL-team_id path
// for solo companies.
async function insertTeam(params: { createdBy: string; name?: string }): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.teams (id, name, created_by)
VALUES ($1, $2, $3)`,
[id, params.name ?? 'Test Team', params.createdBy],
)
// Owner is stored as a team_members row with role='owner' (per
// 20260331010000_teams_table_refactor.sql section 3c).
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role)
VALUES ($1, $2, 'owner')`,
[id, params.createdBy],
)
return id
}
async function insertTeamMember(params: {
teamId: string
userId: string
role?: 'admin' | 'member'
}): Promise<void> {
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role)
VALUES ($1, $2, $3)`,
[params.teamId, params.userId, params.role ?? 'member'],
)
}
describe('create_company_with_owner: team_id authorization', () => {
it('raises when caller is not a member of the requested team', async () => {
const ownerId = await insertAuthUser()
const intruderId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
await expect(
withUserContext(intruderId, async (client) => {
await client.query(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Intruder AB', 'aktiebolag', false, teamId],
)
}),
).rejects.toThrow(/Not a member of team/)
})
it('succeeds when caller is the team owner', async () => {
const ownerId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
const companyId = await withUserContext(ownerId, async (client) => {
const { rows } = await client.query<{ create_company_with_owner: string }>(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Owner AB', 'aktiebolag', false, teamId],
)
return rows[0]!.create_company_with_owner
})
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
})
it('succeeds when caller is a team member (not owner)', async () => {
const ownerId = await insertAuthUser()
const memberId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
await insertTeamMember({ teamId, userId: memberId, role: 'member' })
const companyId = await withUserContext(memberId, async (client) => {
const { rows } = await client.query<{ create_company_with_owner: string }>(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Member AB', 'aktiebolag', false, teamId],
)
return rows[0]!.create_company_with_owner
})
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
})
it('skips the membership check when p_team_id is NULL (solo company path)', async () => {
const soloId = await insertAuthUser()
const companyId = await withUserContext(soloId, async (client) => {
const { rows } = await client.query<{ create_company_with_owner: string }>(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Solo EF', 'enskild_firma', false, null],
)
return rows[0]!.create_company_with_owner
})
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
})
it('uses insufficient_privilege (SQLSTATE 42501) for the team-membership rejection', async () => {
const ownerId = await insertAuthUser()
const intruderId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
let sqlstate: string | undefined
try {
await withUserContext(intruderId, async (client) => {
await client.query(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Intruder AB', 'aktiebolag', false, teamId],
)
})
} catch (err) {
sqlstate = (err as { code?: string }).code
}
expect(sqlstate).toBe('42501')
})
})