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
144 changes: 113 additions & 31 deletions src/services/ddoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,66 @@
let nquads;
try {
nquads = await jsonld.toRDF(ddoCopy, { format: 'application/n-quads' });
} catch {
extraErrors.output = ['Output is null or invalid'];
} catch (error) {
extraErrors.general = [`Failed to convert DDO to RDF: ${error}`];
return null;
}
const data = new Store(new N3Parser().parse(nquads as string));

// A near-empty graph means JSON-LD expansion dropped the DDO vocabulary
// (e.g. an unmapped `@context` such as the raw Verifiable Credentials v2
// context). SHACL would then find no target nodes and "conform" vacuously,
// so a broken DDO would validate as true. Treat that as a hard failure
// instead of silently passing.
if (data.size === 0) {
extraErrors.general = [
'DDO could not be expanded into RDF; no statements were produced.'
];
return null;
}

const validator = new SHACLValidator(shapes);
return validator.validate(data);
}

/**
* Recursively collects SHACL violations into field-level errors.
*
* rdf-validate-shacl reports a failed `sh:node` constraint as a generic
* "Value does not have shape X" on the *parent* path, and nests the real,
* per-field violation under `result.detail`. Walking into `detail` lets us
* key the error by the actual failing field (e.g. `name`, `timeout`) rather
* than the opaque parent shape, which is essential for the deeply nested v5
* `credentialSubject` structure.
*
* @param results - SHACL validation results (or a nested `detail` array).
* @param extraErrors - Accumulator keyed by field name.
* @param vocab - Namespace IRI to strip from result paths to get the key.
*/
protected collectShaclViolations(
results: any[],
extraErrors: Record<string, string[]>,
vocab: string
): void {
for (const result of results) {
if (Array.isArray(result?.detail) && result.detail.length > 0) {
this.collectShaclViolations(result.detail, extraErrors, vocab);
continue;
}
const rawPath = result?.path?.value;
const key = rawPath ? rawPath.replace(vocab, '') : '';
if (!key) continue;
const term = result?.message?.[0];
let message: string;
if (term == null) message = 'Invalid value';
else if (typeof term === 'string') message = term;
else if (typeof term.value === 'string') message = term.value;
else message = String(fromRdf(term));
if (!(key in extraErrors)) extraErrors[key] = [];
if (!extraErrors[key].includes(message)) extraErrors[key].push(message);
}
}

/**
* Factory method to get a DDO class instance based on version.
* @param ddoData - The DDO data object.
Expand All @@ -172,7 +222,7 @@

// V4 DDO implementation
export class V4DDO extends DDOManager {
public constructor(ddoData: Record<string, any>) {

Check warning on line 225 in src/services/ddoManager.ts

View workflow job for this annotation

GitHub Actions / lint

Useless constructor
super(ddoData);
}

Expand Down Expand Up @@ -226,36 +276,44 @@
const ddoCopy = JSON.parse(JSON.stringify(updatedDdo));
const { chainId, nftAddress } = ddoCopy;
const extraErrors: Record<string, string[]> = {};
const SCHEMA_VOCAB = 'http://schema.org/';

ddoCopy['@type'] = 'DDO';
ddoCopy['@context'] = { '@vocab': 'http://schema.org/' };
ddoCopy['@context'] = { '@vocab': SCHEMA_VOCAB };

if (!chainId) {
extraErrors.chainId = ['chainId is missing or invalid.'];
}

let validAddress = false;
try {
getAddress(nftAddress);
validAddress = true;
} catch {
extraErrors.nftAddress = ['nftAddress is missing or invalid.'];
}

if (this.makeDid(nftAddress, chainId.toString(10)) !== ddoCopy.id) {
extraErrors.id = ['did is not valid for chain Id and nft address'];
// Only derive the DID when the inputs makeDid needs are present. A missing
// chainId or an empty/invalid nftAddress would otherwise make ethers'
// getAddress() throw a raw TypeError; we surface a clean field error above.
if (validAddress && chainId) {
try {
if (this.makeDid(nftAddress, chainId.toString(10)) !== ddoCopy.id) {
extraErrors.id = ['did is not valid for chain Id and nft address'];
}
} catch {
extraErrors.id = [
'did could not be derived from chain Id and nft address'
];
}
}

const report = await this.runShaclValidation(ddoCopy, extraErrors);
if (!report) return [false, extraErrors];

if (report.conforms) return [true, {}];

for (const result of report.results) {
const key = result.path?.value.replace('http://schema.org/', '');
if (key) {
if (!(key in extraErrors)) extraErrors[key] = [];
extraErrors[key].push(fromRdf(result.message[0]));
}
}
this.collectShaclViolations(report.results, extraErrors, SCHEMA_VOCAB);
extraErrors.fullReport = report.dataset.toString();
return [false, extraErrors];
}
Expand All @@ -263,7 +321,7 @@

// V5 DDO implementation
export class V5DDO extends DDOManager {
public constructor(ddoData: Record<string, any>) {

Check warning on line 324 in src/services/ddoManager.ts

View workflow job for this annotation

GitHub Actions / lint

Useless constructor
super(ddoData);
}

Expand Down Expand Up @@ -328,49 +386,73 @@
async validate(): Promise<[boolean, Record<string, string[]>]> {
const updatedDdo = this.deleteIndexedMetadataIfExists(this.getDDOData());
const ddoCopy = JSON.parse(JSON.stringify(updatedDdo));
const { chainId, nftAddress } = ddoCopy.credentialSubject;
const extraErrors: Record<string, string[]> = {};
// The v5 SHACL shape (schemas/5.0.0.ttl) declares
// `@prefix schema: <https://www.w3.org/ns/credentials/v2/>`, so its
// property IRIs live in that namespace. Setting the DDO's `@context` to a
// matching `@vocab` maps every DDO / credentialSubject term
// (metadata, services, nftAddress, ...) onto those exact IRIs during
// JSON-LD expansion. Without this, the document's own VC v2 `@context`
// does not define the Ocean vocabulary, so `toRDF` would drop all those
// terms and SHACL would have nothing to validate.
const VC_VOCAB = 'https://www.w3.org/ns/credentials/v2/';

// A VerifiableCredential DDO carries its real payload under
// `credentialSubject`; without it there is nothing to validate.
const { credentialSubject } = ddoCopy;
if (!credentialSubject || typeof credentialSubject !== 'object') {
extraErrors.credentialSubject = [
'credentialSubject is missing or invalid.'
];
return [false, extraErrors];
}

const { chainId, nftAddress } = credentialSubject;

ddoCopy['@type'] = 'VerifiableCredential';
ddoCopy['@context'] = { '@vocab': 'https://www.w3.org/ns/credentials/v2/' };
ddoCopy['@context'] = { '@vocab': VC_VOCAB };

if (!ddoCopy.credentialSubject.chainId) {
if (!chainId) {
extraErrors.chainId = ['chainId is missing or invalid.'];
}

let validAddress = false;
try {
getAddress(nftAddress);
validAddress = true;
} catch {
extraErrors.nftAddress = ['nftAddress is missing or invalid.'];
}

if (this.makeDid(nftAddress, chainId.toString(10)) !== ddoCopy.id) {
extraErrors.id = ['did is not valid for chainId and nft address'];
}

if (!ddoCopy.credentialSubject.metadata) {
if (!credentialSubject.metadata) {
extraErrors.metadata = ['metadata is missing or invalid.'];
}

if (!ddoCopy.credentialSubject.services) {
if (!credentialSubject.services) {
extraErrors.services = ['services are missing or invalid.'];
}

// Only derive the DID when the inputs makeDid needs are present. A missing
// chainId or an empty/invalid nftAddress would otherwise make ethers'
// getAddress() throw a raw TypeError; we surface a clean field error above.
if (validAddress && chainId) {
try {
if (this.makeDid(nftAddress, chainId.toString(10)) !== ddoCopy.id) {
extraErrors.id = ['did is not valid for chainId and nft address'];
}
} catch {
extraErrors.id = [
'did could not be derived from chainId and nft address'
];
}
}

const report = await this.runShaclValidation(ddoCopy, extraErrors);
if (!report) return [false, extraErrors];

if (report.conforms) return [true, {}];

for (const result of report.results) {
const key = result?.path?.value.replace(
'https://www.w3.org/ns/credentials/v2/',
''
);
if (key) {
if (!(key in extraErrors)) extraErrors[key] = [];
extraErrors[key].push(result.message[0].value);
}
}
this.collectShaclViolations(report.results, extraErrors, VC_VOCAB);
extraErrors.fullReport = report.dataset.toString();
return [false, extraErrors];
}
Expand Down
19 changes: 19 additions & 0 deletions src/test/data/ddo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,22 @@ export const invalidDDOV5 = {
type: ['VerifiableCredential'],
additionalDdos: [{ type: '', data: '' }]
};

// Recursively freezes an object so nested fields (e.g. credentialSubject.metadata)
// cannot be mutated. Tests must structuredClone() before mutating.
function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
for (const key of Object.keys(value as Record<string, unknown>)) {
deepFreeze((value as Record<string, unknown>)[key]);
}
}
return value;
}

// Immutable, self-contained copy of the valid v5 enterprise DDO. Other test
// suites mutate DDOExampleV5 in place (via updateFields), so this deep clone is
// captured at module-eval time and deep-frozen to give the validation tests a
// stable, well-formed baseline whose `id` still matches makeDid(nftAddress,
// chainId). structuredClone() yields a mutable copy, so tests clone before use.
export const validEnterpriseDDOV5 = deepFreeze(structuredClone(DDOExampleV5));
63 changes: 62 additions & 1 deletion src/test/unit/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
deprecatedDDO,
invalidDDOV4,
invalidDDOV5,
invalidDeprecatedDDO
invalidDeprecatedDDO,
validEnterpriseDDOV5
} from '../data/ddo.js';

describe('DDOManager Validation Tests', () => {
Expand Down Expand Up @@ -73,6 +74,66 @@ describe('DDOManager Validation Tests', () => {
);
});

it('should validate a valid V5 enterprise DDO into a full RDF graph (not vacuously)', async () => {
// Guards against the "Output is null or invalid" regression: the VC-format
// DDO must expand into the Ocean vocabulary so SHACL has real target nodes.
const validCopy = structuredClone(validEnterpriseDDOV5);
const validationResult = await validateDDO(validCopy);
expect(validationResult[0]).to.eql(true);
expect(validationResult[1]).to.eql({});
});

it('should fail V5 DDO validation with a specific per-field error for a missing nested field', async () => {
// Remove a deeply-nested required field (metadata.name). The fix must
// surface the actual failing field key rather than the opaque parent
// "Value does not have shape CredentialSubjectShape" message, and must
// never return "Output is null or invalid".
const invalidCopy = structuredClone(validEnterpriseDDOV5);
delete invalidCopy.credentialSubject.metadata.name;

const validationResult = await validateDDO(invalidCopy);
expect(validationResult[0]).to.eql(false);
expect(validationResult[1]).to.have.property('name');
expect(validationResult[1].name.join(' ')).to.match(/less than 1 values/i);
expect(validationResult[1]).to.not.have.property('output');
expect(JSON.stringify(validationResult[1])).to.not.include(
'Output is null or invalid'
);
});

it('should return a clean field-level error (not a raw throw) for an empty nftAddress', async () => {
const invalidCopy = structuredClone(validEnterpriseDDOV5);
invalidCopy.credentialSubject.nftAddress = '';

const validationResult = await validateDDO(invalidCopy);
expect(validationResult[0]).to.eql(false);
expect(validationResult[1]).to.have.property('nftAddress');
expect(validationResult[1].nftAddress).to.include(
'nftAddress is missing or invalid.'
);
// The raw ethers "invalid address" TypeError must not leak through.
expect(JSON.stringify(validationResult[1])).to.not.include(
'invalid address'
);
expect(validationResult[1]).to.not.have.property('general');
});

it('should return a clean field-level error for an absent nftAddress', async () => {
const invalidCopy = structuredClone(validEnterpriseDDOV5);
delete invalidCopy.credentialSubject.nftAddress;

const validationResult = await validateDDO(invalidCopy);
expect(validationResult[0]).to.eql(false);
expect(validationResult[1]).to.have.property('nftAddress');
expect(validationResult[1].nftAddress).to.include(
'nftAddress is missing or invalid.'
);
expect(JSON.stringify(validationResult[1])).to.not.include(
'invalid address'
);
expect(validationResult[1]).to.not.have.property('general');
});

it('should return a valid DID for V4 DDO', () => {
const ddoInstance = new V4DDO(DDOExampleV4);
const did = ddoInstance.makeDid(DDOExampleV4.nftAddress, '137');
Expand Down
Loading