Skip to content
Open
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
1 change: 1 addition & 0 deletions K6/api/building-blocks/register/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { RemoveRevisorRoleFromEr } from "./remove-revisor-role-from-er.js";
export { AddRevisorRoleToErForOrg } from "./add-revisor-role-to-er-for-org.js";
export { GetRevisorCustomerIdentifiersForParty } from "./get-revisor-customer-identifiers-for-party.js";
export { LookUpPartyInRegister as LookupPartiesInRegister } from "./look-up-parties.js";
export { SubmitErData } from "./submit-er-data.js";
29 changes: 29 additions & 0 deletions K6/api/building-blocks/register/submit-er-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { check } from "k6";
import { RegisterApiClient } from "../../../clients/authentication/index.js";

/**
* Posts a SubmitERDataBasic SOAP envelope to the ER update endpoint and checks
* that the response indicates successful processing.
*
* Credentials must be interpolated into the SOAP body before calling this function.
*
* @param {RegisterApiClient} registerClient
* @param {string} soapBody - Complete SOAP envelope with credentials already set
* @returns (string | ArrayBuffer | null)
*/
export function SubmitErData(registerClient, soapBody, label = "SubmitErData") {
const res = registerClient.SubmitErData(soapBody);

const ok = check(res, {
[`${label} - status code MUST be 200`]: (r) => r.status === 200,
[`${label} - response contains OK_ER_DATA_PROCESSED`]: (r) =>
r.body && r.body.includes("status=\"OK_ER_DATA_PROCESSED\""),
});

if (!ok) {
console.error(res.status);
console.error(res.body);
}

return res;
}
25 changes: 25 additions & 0 deletions K6/api/tests/register/er-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# ER Sync Tests

Verifies that data submitted to the Enhetsregisteret (ER) via SOAP is correctly synced to Altinn Register. Each test submits a **prep** state (creates the organization), then a **change**, and finally verifies the change is reflected in Register.

## Running

```bash
# Run all tests
k6 run run-all.js -e ENVIRONMENT=at22 -e BASE_URL=https://platform.at22.altinn.cloud \
-e SOAP_ER_USERNAME=<u> -e SOAP_ER_PASSWORD=<p> -e REGISTER_SUBSCRIPTION_KEY=<key>

# Stop after prep (seed state, inspect manually in portal)
k6 run change-styr.js -e STOP_AFTER_PREP=true <env vars>
k6 run run-all.js -e STOP_AFTER_PREP=true <env vars>
```

### Required environment variables

| Variable | Description |
|---|---|
| `ENVIRONMENT` | Target environment, e.g. `at22`, `at23`, `tt02` |
| `BASE_URL` | Base URL for the Register API |
| `SOAP_ER_USERNAME` | Username for the ER SOAP endpoint |
| `SOAP_ER_PASSWORD` | Password for the ER SOAP endpoint |
| `REGISTER_SUBSCRIPTION_KEY` | Subscription key for the Register API |
50 changes: 50 additions & 0 deletions K6/api/tests/register/er-sync/er-sync-summary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Custom k6 summary handler for ER sync tests.
*
* Outputs one line per testcase:
* ✅ testcase-name (all checks passed)
* ❌ testcase-name (one or more checks failed)
* - failing check name
* - another failing check name
*/

function collectFailingChecks(group) {
const failing = [];
for (const c of group.checks || []) {
if (c.fails > 0) failing.push(c.name);
}
for (const child of group.groups || []) {
failing.push(...collectFailingChecks(child));
}
return failing;
}

export function handleSummary(data) {
const lines = ["\nER Sync Test Results", "=".repeat(40)];
let passed = 0;
let failed = 0;

const groups = (data.root_group.groups || [])
.filter((g) => g.name !== "Cleanup" && !g.name.startsWith("Verify -"))
.sort((a, b) => parseInt(a.name) - parseInt(b.name));

for (const group of groups) {
const failingChecks = collectFailingChecks(group);

if (failingChecks.length === 0) {
lines.push(`✅ ${group.name}`);
passed++;
} else {
lines.push(`❌ ${group.name}`);
for (const name of failingChecks) {
lines.push(` ❌ ${name}`);
}
failed++;
}
}

const total = passed + failed;
lines.push(`\n${passed}/${total} testcases passed\n`);

return { stdout: lines.join("\n") };
}
124 changes: 124 additions & 0 deletions K6/api/tests/register/er-sync/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { group, check, fail } from "k6";
import { EnterpriseTokenGenerator, PlatformTokenGenerator } from "../../../../common-imports.js";
import { AuthorizedPartiesClient, RegisterApiClient, RegisterLookupClient } from "../../../../clients/authentication/index.js";
import { GetAuthorizedParties } from "../../../building-blocks/authentication/authorized-parties/index.js";
import { SubmitErData } from "../../../building-blocks/register/index.js";
import { retry } from "../../../../helpers.js";

for (const key of ["ENVIRONMENT", "BASE_URL", "SOAP_ER_USERNAME", "SOAP_ER_PASSWORD"]) {
if (!__ENV[key]) throw new Error(`Missing required env var: ${key}`);
}

export function buildErSoapEnvelope(batchXml) {
return `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="http://www.altinn.no/services/Register/ER/2013/06">
<soapenv:Header/>
<soapenv:Body>
<ns:SubmitERDataBasic>
<ns:systemUserName>${__ENV.SOAP_ER_USERNAME}</ns:systemUserName>
<ns:systemPassword>${__ENV.SOAP_ER_PASSWORD}</ns:systemPassword>
<ns:ERData><![CDATA[<?xml version="1.0" encoding="UTF-8"?>
${batchXml}]]></ns:ERData>
</ns:SubmitERDataBasic>
</soapenv:Body>
</soapenv:Envelope>`;
}

export function createAuthorizedPartiesClient() {
const tokenOpts = new Map();
tokenOpts.set("env", __ENV.ENVIRONMENT);
tokenOpts.set("ttl", 3600);
tokenOpts.set("scopes", "altinn:accessmanagement/authorizedparties.resourceowner");
return new AuthorizedPartiesClient(__ENV.BASE_URL, new EnterpriseTokenGenerator(tokenOpts));
}

export function retryUntilHasAccess(apClient, fnr, orgNr, scenario) {
let result = null;
retry(
() => {
const parties = GetAuthorizedParties(apClient, "urn:altinn:person:identifier-no", fnr, { includeAltinn2: false, includePartiesViaKeyRoles: true });
if (!Array.isArray(parties)) return false;
const hasAccess = parties.some((p) => p.organizationNumber === orgNr || p.orgNumber === orgNr);
if (hasAccess) result = parties;
return hasAccess;
},
{ retries: 15, intervalSeconds: 20, testscenario: scenario },
);
return result;
}

export function retryUntilNoAccess(apClient, fnr, orgNr, scenario) {
let result = null;
retry(
() => {
const parties = GetAuthorizedParties(apClient, "urn:altinn:person:identifier-no", fnr, { includeAltinn2: false, includePartiesViaKeyRoles: true });
if (!Array.isArray(parties)) return false;
const noAccess = !parties.some((p) => p.organizationNumber === orgNr || p.orgNumber === orgNr);
if (noAccess) result = parties;
return noAccess;
},
{ retries: 15, intervalSeconds: 20, testscenario: scenario },
);
return result;
}

function pollOrganization(lookupClient, orgNr) {
const res = lookupClient.LookupParties("party,person,org,user,si,sysuser", { data: [`urn:altinn:organization:identifier-no:${orgNr}`] });
if (res.status !== 200) return null;
const body = JSON.parse(res.body);
return body.data[0] || null;
}

export function runErSyncTestcase(scenarioName, prepXml, changeXml, orgNr, verifyChecks = {}, { stopAfterPrep = false } = {}) {
const tokenOpts = new Map();
tokenOpts.set("env", __ENV.ENVIRONMENT);
tokenOpts.set("ttl", 3600);

const apiClient = new RegisterApiClient(__ENV.BASE_URL, null);
const lookupClient = new RegisterLookupClient(__ENV.BASE_URL, new PlatformTokenGenerator(tokenOpts));

group(scenarioName, () => {
group("Prep - submit organization to ER", () => {
const res = SubmitErData(apiClient, prepXml, "Prep");
if (res.status !== 200) fail(`[${scenarioName}] Prep failed with status ${res.status} — aborting test`);
});

group("Prep - verify organization is visible in Register", () => {
let prepParty = null;
retry(
() => {
const party = pollOrganization(lookupClient, orgNr);
if (!party) return false;
prepParty = party;
return true;
},
{ retries: 15, intervalSeconds: 20, testscenario: `${scenarioName} - prep` },
);
if (prepParty) {
check(prepParty, { "Prep - org is visible in Register": (p) => p.organizationIdentifier === orgNr });
}
});

if (stopAfterPrep || __ENV.STOP_AFTER_PREP === "true") return;

group("Change - submit ER update", () => {
SubmitErData(apiClient, changeXml, "Change");
});

group("Verify - Register reflects change", () => {
let verifiedParty = null;
retry(
() => {
const party = pollOrganization(lookupClient, orgNr);
if (!party || !Object.values(verifyChecks).every((fn) => fn(party))) return false;
verifiedParty = party;
return true;
},
{ retries: 15, intervalSeconds: 20, testscenario: `${scenarioName} - verify` },
);
if (verifiedParty) {
check(verifiedParty, verifyChecks);
}
});

});
}
38 changes: 38 additions & 0 deletions K6/api/tests/register/er-sync/run-all.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { nameShortChange } from "./testcase_1_change_org_name.js";
import { addMedl } from "./testcase_2_add_styremedlem.js";
import { removeMedl } from "./testcase_3_remove_styremedlem.js";
import { daglChange } from "./testcase_4_replace_daglig_leder.js";
import { styrChange } from "./testcase_5_replace_styreleder.js";
import { fadrChange } from "./testcase_6_change_forretningsadresse.js";
import { contactChange } from "./testcase_7_update_contact_info.js";
import { addFmva } from "./testcase_8_add_frivillig_mva.js";
import { kasteStyret } from "./testcase_9_kaste_styret.js";

/**
* @file run-all.js
* @description Runs all ER sync testcases as named scenarios.
*
* k6 run run-all.js \
* -e ENVIRONMENT=at22 -e BASE_URL=https://platform.at22.altinn.cloud \
* -e SOAP_ER_USERNAME=<u> -e SOAP_ER_PASSWORD=<p> \
* -e REGISTER_SUBSCRIPTION_KEY=<key>
*/

export const options = {
scenarios: {
"testcase-1-change-org-name": { executor: "shared-iterations", exec: "nameShortChange", vus: 1, iterations: 1 },
"testcase-2-add-styremedlem": { executor: "shared-iterations", exec: "addMedl", vus: 1, iterations: 1 },
"testcase-3-remove-styremedlem": { executor: "shared-iterations", exec: "removeMedl", vus: 1, iterations: 1 },
"testcase-4-replace-daglig-leder": { executor: "shared-iterations", exec: "daglChange", vus: 1, iterations: 1 },
"testcase-5-replace-styreleder": { executor: "shared-iterations", exec: "styrChange", vus: 1, iterations: 1 },
"testcase-6-change-forretningsadresse":{ executor: "shared-iterations", exec: "fadrChange", vus: 1, iterations: 1 },
"testcase-7-update-contact-info": { executor: "shared-iterations", exec: "contactChange", vus: 1, iterations: 1 },
"testcase-8-add-frivillig-mva": { executor: "shared-iterations", exec: "addFmva", vus: 1, iterations: 1 },
"testcase-9-kaste-styret": { executor: "shared-iterations", exec: "kasteStyret", vus: 1, iterations: 1 },
},
};

export { nameShortChange, addFmva, fadrChange, daglChange, contactChange, styrChange, addMedl, removeMedl, kasteStyret };

// Reporting tools
export { handleSummary } from "./er-sync-summary.js";
101 changes: 101 additions & 0 deletions K6/api/tests/register/er-sync/testcase_10_delete_org.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { group, check } from "k6";
import { RegisterApiClient } from "../../../../clients/authentication/index.js";
import { SubmitErData } from "../../../building-blocks/register/index.js";
import { generateOrgNr } from "../../../../helpers.js";
import { runErSyncTestcase, buildErSoapEnvelope, createAuthorizedPartiesClient, retryUntilHasAccess, retryUntilNoAccess } from "./helper.js";

/**
* @file testcase_10_delete_org.js
* @description Verifies that deleting an organization (hovedsakstype="S") in ER
* is correctly synced to Altinn Register and revokes access for existing role holders.
* @see README.md
*/

export const options = {
scenarios: {
"testcase-10-delete-org": { executor: "shared-iterations", exec: "deleteOrg", vus: 1, iterations: 1 },
},
};

const DAGLIG_LEDER = { fnr: "22876298973", fornavn: "OFFISIELL", slektsnavn: "ÆRESDOKTOR" };

function buildPrepXml(orgNr) {
return buildErSoapEnvelope(`<batchAjourholdXML>
<head avsender="ER" dato="20260512" kjoerenr="00150" mottaker="ALT" type="A" />
<enhet organisasjonsnummer="${orgNr}" organisasjonsform="AS" hovedsakstype="N" undersakstype="NY" foersteOverfoering="J" datoFoedt="20200101" datoSistEndret="20260512">
<infotype felttype="NAVN" endringstype="N">
<navn1>SLETT ORG TEST AS</navn1>
<rednavn>SLETT ORG TEST AS</rednavn>
</infotype>
<infotype felttype="FADR" endringstype="N">
<postnr>0150</postnr>
<landkode>NO</landkode>
<kommunenr>0301</kommunenr>
<adresse1>Testveien 1</adresse1>
</infotype>
<samendringer data="D" felttype="DAGL" endringstype="N" type="R">
<rolleFratraadt>N</rolleFratraadt>
<rolleRekkefoelge>1</rolleRekkefoelge>
<rolleFoedselsnr>${DAGLIG_LEDER.fnr}</rolleFoedselsnr>
<fornavn>${DAGLIG_LEDER.fornavn}</fornavn>
<slektsnavn>${DAGLIG_LEDER.slektsnavn}</slektsnavn>
<postnr>0150</postnr>
<adresse1>Testveien 10</adresse1>
<adresseLandkode>NO</adresseLandkode>
<personstatus>L</personstatus>
</samendringer>
</enhet>
<trai antallEnheter="1" avsender="ER" />
</batchAjourholdXML>`);
}

export function deleteOrg() {
const orgNr = generateOrgNr();
console.log(`[TC10] orgNr: ${orgNr} | DAGLIG_LEDER: ${DAGLIG_LEDER.fnr} (${DAGLIG_LEDER.fornavn} ${DAGLIG_LEDER.slektsnavn})`);

const prep = buildPrepXml(orgNr);

const change = buildErSoapEnvelope(`<batchAjourholdXML>
<head avsender="ER" dato="20260512" kjoerenr="00250" mottaker="ALT" type="A" />
<enhet organisasjonsnummer="${orgNr}" organisasjonsform="AS" hovedsakstype="S" undersakstype="SL" foersteOverfoering="N" datoFoedt="20200101" datoSistEndret="20260512">
</enhet>
<trai antallEnheter="1" avsender="ER" />
</batchAjourholdXML>`);

const apClient = createAuthorizedPartiesClient();

// Phase 1: Prep - register org with DAGL and wait for Register
runErSyncTestcase(
"10. Slett org - Prep",
prep,
change,
orgNr,
{},
{ stopAfterPrep: true },
);

// Phase 2: Confirm DAGL has access before testing deletion
group("Verify - DAGL has access to org after prep", () => {
const parties = retryUntilHasAccess(apClient, DAGLIG_LEDER.fnr, orgNr, "10. Slett org - DAGL access granted");
console.log(`[TC10] Authorized parties for ${DAGLIG_LEDER.fornavn} ${DAGLIG_LEDER.slektsnavn} after prep: ${JSON.stringify(parties)}`);
});

// Phase 3: Submit org deletion
group("Change - submit org deletion", () => {
const apiClient = new RegisterApiClient(__ENV.BASE_URL, null);
SubmitErData(apiClient, change, "Change");
});

// Phase 4: Verify DAGL no longer has access after org deletion
group("Verify - DAGL no longer has access after org deletion", () => {
const parties = retryUntilNoAccess(apClient, DAGLIG_LEDER.fnr, orgNr, "10. Slett org - DAGL access revoked");
console.log(`[TC10] Authorized parties for ${DAGLIG_LEDER.fornavn} ${DAGLIG_LEDER.slektsnavn} after deletion: ${JSON.stringify(parties)}`);
check(parties, {
[`${DAGLIG_LEDER.fornavn} ${DAGLIG_LEDER.slektsnavn} no longer has access to deleted org`]: (p) =>
Array.isArray(p) && !p.some((party) => party.organizationNumber === orgNr || party.orgNumber === orgNr),
});
});
}

// Reporting tools
export { handleSummary } from "./er-sync-summary.js";
Loading