Skip to content

Commit 9596c54

Browse files
Merge pull request #10 from limosa-io/tests-in-pipeline
Fixes #7
2 parents 7008d25 + ddcff4c commit 9596c54

File tree

7 files changed

+146
-46
lines changed

7 files changed

+146
-46
lines changed

.github/workflows/ci.yml

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,52 @@ name: CI
22

33
on:
44
push:
5-
branches:
6-
- main
5+
pull_request:
76

87
jobs:
9-
build:
8+
test:
109
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v3
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v3
21+
with:
22+
node-version: "18"
23+
24+
- name: Install dependencies
25+
run: npm install
26+
27+
- name: Build image
28+
run: docker build -t scimverify-test .
29+
30+
- name: Test against Laravel SCIM Server
31+
run: |
32+
# Start the Laravel SCIM server in the background
33+
docker run -d -p 8000:8000 --name laravel-scim-server ghcr.io/limosa-io/laravel-scim-server:latest
34+
35+
# Wait for server to be ready
36+
timeout 60 bash -c 'until curl -f http://localhost:8000/scim/v2/Schemas; do sleep 2; done'
37+
38+
sed -i 's/requireAuthentication: true/requireAuthentication: false/' ./site/.vitepress/theme/components/config.yaml
39+
40+
# Run scimverify tests against the server
41+
npx node ./bin/scimverify.js --config ./site/.vitepress/theme/components/config.yaml --base-url http://localhost:8000/scim/v2/ --auth-header "Bearer YOUR_TOKEN"
42+
43+
# Cleanup
44+
docker stop laravel-scim-server
45+
docker rm laravel-scim-server
46+
47+
deploy:
48+
runs-on: ubuntu-latest
49+
needs: test
50+
if: github.ref == 'refs/heads/main'
1151
permissions:
1252
contents: read
1353
packages: write
@@ -49,13 +89,10 @@ jobs:
4989
git_push_flags: '--force'
5090

5191
- name: Build image
52-
run: docker build -t scimverify-test .
92+
run: docker build -t scimverify-test .
5393

5494
- name: Authenticate to GHCR
5595
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin
5696

5797
- name: Push image
5898
run: docker push ghcr.io/${{ github.repository }}:latest
59-
60-
- name: Test Docker image
61-
run: bash ./test-docker.sh

site/.vitepress/theme/components/config.yaml

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ detectSchema: true
22
detectResourceTypes: true
33
verifyPagination: true
44
verifySorting: true
5+
requireAuthentication: false
56

67
users:
78
enabled: true
@@ -97,8 +98,8 @@ users:
9798
- request:
9899
{
99100
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
100-
"userName": "bjensen",
101-
"emails": [{ "value": "barbara.jensen@example.com" }],
101+
"userName": "bjonsen",
102+
"emails": [{ "value": "barbara.jonsen@example.com" }],
102103
}
103104
response:
104105
{
@@ -119,7 +120,7 @@ users:
119120
"type": "object",
120121
"properties":
121122
{
122-
"userName": { "type": "string", "const": "bjensen" },
123+
"userName": { "type": "string", "const": "bjonsen" },
123124
"emails":
124125
{
125126
"type": "array",
@@ -131,7 +132,7 @@ users:
131132
"value":
132133
{
133134
"type": "string",
134-
"const": "barbara.jensen@example.com",
135+
"const": "barbara.jonsen@example.com",
135136
},
136137
},
137138
},
@@ -152,7 +153,7 @@ users:
152153
{
153154
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
154155
"userName": "bdoe",
155-
"emails": [{ "value": "barbara.jensen@example.com" }],
156+
"emails": [{ "value": "barbara.doe@example.com" }],
156157
}
157158
response:
158159
{
@@ -185,7 +186,7 @@ users:
185186
"value":
186187
{
187188
"type": "string",
188-
"const": "barbara.jensen@example.com",
189+
"const": "barbara.doe@example.com",
189190
},
190191
},
191192
},
@@ -317,8 +318,7 @@ groups:
317318
"items":
318319
{
319320
"type": "object",
320-
"properties": { "value": { "type": "string" } },
321-
},
321+
"properties": { "value": { "type": ["string", "number"] } }, },
322322
},
323323
},
324324
"required": ["displayName", "members"],
@@ -365,8 +365,7 @@ groups:
365365
"items":
366366
{
367367
"type": "object",
368-
"properties": { "value": { "type": "string" } },
369-
},
368+
"properties": { "value": { "type": ["string", "number"] } }, },
370369
},
371370
},
372371
"required": ["displayName", "members"],

src/basics.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,17 @@ function runTests(config) {
2020
);
2121
});
2222

23-
test('Authentication should be required for /Users', async function (t) {
24-
const axios = getAxiosInstance(config, t);
25-
const response = await axios.get('/Users', {
26-
headers: {
27-
'Authorization': null
28-
}
23+
if (config?.requireAuthentication !== false) {
24+
test('Authentication should be required for /Users', async function (t) {
25+
const axios = getAxiosInstance(config, t);
26+
const response = await axios.get('/Users', {
27+
headers: {
28+
'Authorization': null
29+
}
30+
});
31+
assert.ok([401, 403].includes(response.status), 'Expected 401 Unauthorized or 403 Forbidden status');
2932
});
30-
assert.ok([401, 403].includes(response.status), 'Expected 401 Unauthorized or 403 Forbidden status');
31-
});
33+
}
3234
});
3335
}
3436

src/groups.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import test from 'node:test';
22
import assert from 'node:assert';
3-
import { getAxiosInstance, canonicalize } from './helpers.js';
3+
import { getAxiosInstance, canonicalize, getResourceAttributeValue } from './helpers.js';
44
import dotenv from 'dotenv';
55
import Ajv from 'ajv';
66

@@ -87,7 +87,7 @@ function runTests(groupSchema, groupSchemaExtensions = [], configuration) {
8787
assert.strictEqual(response.status, 200, 'GET /Groups/{id} should return status code 200');
8888
assert.strictEqual(response.data.schemas[0], 'urn:ietf:params:scim:schemas:core:2.0:Group', 'Response should contain the correct schema');
8989
assert.strictEqual(response.data.id, firstGroup.id, 'Returned group ID should match requested group ID');
90-
assert.ok(response.data.displayName, 'Group should contain displayName attribute');
90+
assert.ok(getResourceAttributeValue(response.data, 'displayName'), 'Group should contain displayName attribute');
9191
// Strip content type parameters (charset, boundary) from content-type header
9292
const contentType = response.headers['content-type']?.split(';')[0].trim();
9393
assert.ok(
@@ -124,9 +124,23 @@ function runTests(groupSchema, groupSchemaExtensions = [], configuration) {
124124
assert.strictEqual(response.status, 200, 'Sort request should return 200 OK');
125125
assert.strictEqual(response.data.schemas[0], 'urn:ietf:params:scim:api:messages:2.0:ListResponse', 'Response should use the correct SCIM list response schema');
126126
const groups = response.data.Resources;
127-
for (let i = 1; i < groups.length; i++) {
128-
assert.ok(groups[i - 1].displayName <= groups[i].displayName, 'Groups should be sorted by displayName');
127+
128+
const displayNames = groups.map(g => getResourceAttributeValue(g, 'displayName'));
129+
const unsortedPairs = [];
130+
for (let i = 1; i < displayNames.length; i++) {
131+
if (displayNames[i - 1] > displayNames[i]) {
132+
unsortedPairs.push({ index: i - 1, a: displayNames[i - 1], b: displayNames[i] });
133+
}
134+
}
135+
if (unsortedPairs.length > 0) {
136+
t.diagnostic(
137+
'Unsorted displayName pairs: ' +
138+
unsortedPairs
139+
.map(p => `(${p.index}->${p.index + 1}: "${p.a}" > "${p.b}")`)
140+
.join(', ')
141+
);
129142
}
143+
assert.strictEqual(unsortedPairs.length, 0, 'Groups should be sorted by displayName');
130144
});
131145
}
132146

@@ -162,7 +176,7 @@ function runTests(groupSchema, groupSchemaExtensions = [], configuration) {
162176
const response = await testAxios.post('/Groups', newGroup);
163177
assert.strictEqual(response.status, 400, 'Creating an invalid group should return status code 400');
164178
assert.strictEqual(response.data.scimType, "invalidSyntax", 'Error should have scimType set to invalidSyntax');
165-
assert.strictEqual(response.data.status, '400', 'Error response status should match HTTP status code');
179+
assert.strictEqual(response.data.status, 400, 'Error response status should match HTTP status code');
166180
assert.strictEqual(response.data.schemas[0], 'urn:ietf:params:scim:api:messages:2.0:Error', 'Error response should contain the correct error schema');
167181
});
168182
}
@@ -250,7 +264,12 @@ function runTests(groupSchema, groupSchemaExtensions = [], configuration) {
250264
});
251265
assert.strictEqual(patchResponse.status, 200, 'PATCH /Groups/{id} should return status code 200 when assigning a user to a group');
252266
assert.strictEqual(patchResponse.data.schemas[0], 'urn:ietf:params:scim:schemas:core:2.0:Group', 'Response should contain the correct schema');
253-
assert.ok(patchResponse.data.members.some(member => member.value === user.id), 'User should be assigned to the group');
267+
assert.ok(
268+
getResourceAttributeValue(patchResponse.data, 'members').some(
269+
member => String(member.value) === String(user.id)
270+
),
271+
'User should be assigned to the group'
272+
);
254273
});
255274
}
256275

@@ -274,4 +293,4 @@ function runTests(groupSchema, groupSchemaExtensions = [], configuration) {
274293
});
275294
}
276295

277-
export default runTests;
296+
export default runTests;

src/helpers.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,27 @@ export function canonicalize(resource, schema){
156156

157157
return canonicalizedResource;
158158
}
159+
160+
/**
161+
* Retrieve the value of a SCIM attribute that may be present either at the
162+
* resource root or under the main schema object (first entry in `schemas`).
163+
* Only supports top-level attributes (no nested path parsing).
164+
*
165+
* @param {object} resource - SCIM resource object
166+
* @param {string} attribute - Attribute name to fetch (e.g., 'userName')
167+
* @returns {*} The attribute value or undefined if not found
168+
*/
169+
export function getResourceAttributeValue(resource, attribute) {
170+
if (!resource || !attribute) return undefined;
171+
172+
if (Object.prototype.hasOwnProperty.call(resource, attribute) && resource[attribute] !== undefined) {
173+
return resource[attribute];
174+
}
175+
176+
const mainSchema = Array.isArray(resource.schemas) ? resource.schemas[0] : undefined;
177+
if (mainSchema && resource[mainSchema] && Object.prototype.hasOwnProperty.call(resource[mainSchema], attribute)) {
178+
return resource[mainSchema][attribute];
179+
}
180+
181+
return undefined;
182+
}

src/users.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import test, { skip } from 'node:test';
22
import assert from 'node:assert';
3-
import { getAxiosInstance, canonicalize } from './helpers.js';
3+
import { getAxiosInstance, canonicalize, getResourceAttributeValue } from './helpers.js';
44
import Ajv from 'ajv';
55

66
const sharedState = {};
@@ -119,9 +119,25 @@ function runTests(userSchema, userSchemaExtensions = [], configuration) {
119119
assert.strictEqual(response.status, 200, 'Sort request should return 200 OK');
120120
assert.strictEqual(response.data.schemas[0], 'urn:ietf:params:scim:api:messages:2.0:ListResponse', 'Response should use the correct SCIM list response schema');
121121
const users = response.data.Resources;
122-
for (let i = 1; i < users.length; i++) {
123-
assert.ok(users[i - 1].userName <= users[i].userName, 'Users should be sorted by userName');
122+
123+
// Build list of out-of-order username pairs for better diagnostics on failure
124+
const userNames = users.map(u => getResourceAttributeValue(u, 'userName'));
125+
const unsortedPairs = [];
126+
for (let i = 1; i < userNames.length; i++) {
127+
if (userNames[i - 1] > userNames[i]) {
128+
unsortedPairs.push({ index: i - 1, a: userNames[i - 1], b: userNames[i] });
129+
}
124130
}
131+
132+
if (unsortedPairs.length > 0) {
133+
t.diagnostic(
134+
`Unsorted userName pairs: ` +
135+
unsortedPairs
136+
.map(p => `(${p.index}->${p.index + 1}: "${p.a}" > "${p.b}")`)
137+
.join(', ')
138+
);
139+
}
140+
assert.strictEqual(unsortedPairs.length, 0, 'Users should be sorted by userName');
125141
});
126142
}
127143

@@ -133,7 +149,8 @@ function runTests(userSchema, userSchemaExtensions = [], configuration) {
133149
assert.strictEqual(response.data.schemas[0], 'urn:ietf:params:scim:api:messages:2.0:ListResponse', 'Response should use the correct SCIM list response schema');
134150
const users = response.data.Resources;
135151
users.forEach(user => {
136-
assert.ok(user.hasOwnProperty('userName'), 'User should have userName attribute');
152+
const value = getResourceAttributeValue(user, 'userName');
153+
assert.ok(value !== undefined, 'User should have userName attribute');
137154
});
138155
});
139156

@@ -146,13 +163,23 @@ function runTests(userSchema, userSchemaExtensions = [], configuration) {
146163
return;
147164
}
148165

166+
// Normalize for servers that place core attributes under the main schema object
167+
if (user.userName === undefined) {
168+
const normalizedUserName = getResourceAttributeValue(user, 'userName');
169+
if (normalizedUserName !== undefined) {
170+
user.userName = normalizedUserName;
171+
}
172+
}
173+
174+
const targetUserName = user.userName;
175+
149176
const filter = `userName eq "${user.userName}"`;
150177
const response = await testAxios.get(`/Users?filter=${filter}`);
151178
assert.strictEqual(response.status, 200, 'Filtered users request should return 200 OK');
152179
assert.strictEqual(response.data.schemas[0], 'urn:ietf:params:scim:api:messages:2.0:ListResponse', 'Response should use the correct SCIM list response schema');
153180
const users = response.data.Resources;
154-
users.forEach(user => {
155-
assert.strictEqual(user.userName, user.userName, 'User should have userName equal to "bjensen"');
181+
users.forEach(u => {
182+
assert.strictEqual(getResourceAttributeValue(u, 'userName'), targetUserName, 'User should have matching userName');
156183
});
157184
});
158185

test-docker.sh

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)