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
3 changes: 3 additions & 0 deletions .changes/unreleased/Bugfix-20260113-222243.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Bugfix
body: unescape html strings
time: 2026-01-13T22:22:43.397635-05:00
41 changes: 34 additions & 7 deletions domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package opslevel
import (
"errors"
"fmt"
"html"
"slices"
)

Expand All @@ -18,6 +19,12 @@ func (d *Domain) UniqueIdentifiers() []string {
return uniqueIdentifiers
}

func (d *Domain) Hydrate(client *Client) error {
d.Description = html.UnescapeString(d.Description)
d.Note = html.UnescapeString(d.Note)
return nil
}

func (d *Domain) ReconcileAliases(client *Client, aliasesWanted []string) error {
aliasesToCreate, aliasesToDelete := extractAliases(d.Aliases, aliasesWanted)

Expand Down Expand Up @@ -140,8 +147,13 @@ func (client *Client) CreateDomain(input DomainInput) (*Domain, error) {
v := PayloadVariables{
"input": input,
}
err := client.Mutate(&m, v, WithName("DomainCreate"))
return &m.Payload.Domain, HandleErrors(err, m.Payload.Errors)
if err := client.Mutate(&m, v, WithName("DomainCreate")); err != nil {
return nil, err
}
if err := m.Payload.Domain.Hydrate(client); err != nil {
return &m.Payload.Domain, err
}
return &m.Payload.Domain, HandleErrors(nil, m.Payload.Errors)
}

func (client *Client) GetDomain(identifier string) (*Domain, error) {
Expand All @@ -153,8 +165,13 @@ func (client *Client) GetDomain(identifier string) (*Domain, error) {
v := PayloadVariables{
"input": *NewIdentifier(identifier),
}
err := client.Query(&q, v, WithName("DomainGet"))
return &q.Account.Domain, HandleErrors(err, nil)
if err := client.Query(&q, v, WithName("DomainGet")); err != nil {
return nil, err
}
if err := q.Account.Domain.Hydrate(client); err != nil {
return &q.Account.Domain, err
}
return &q.Account.Domain, nil
}

func (client *Client) ListDomains(variables *PayloadVariables) (*DomainConnection, error) {
Expand All @@ -175,7 +192,12 @@ func (client *Client) ListDomains(variables *PayloadVariables) (*DomainConnectio
if err != nil {
return nil, err
}
q.Account.Domains.Nodes = append(q.Account.Domains.Nodes, resp.Nodes...)
for _, node := range resp.Nodes {
if err := node.Hydrate(client); err != nil {
return nil, err
}
q.Account.Domains.Nodes = append(q.Account.Domains.Nodes, node)
}
q.Account.Domains.PageInfo = resp.PageInfo
}
q.Account.Domains.TotalCount = len(q.Account.Domains.Nodes)
Expand All @@ -190,8 +212,13 @@ func (client *Client) UpdateDomain(identifier string, input DomainInput) (*Domai
"domain": *NewIdentifier(identifier),
"input": input,
}
err := client.Mutate(&m, v, WithName("DomainUpdate"))
return &m.Payload.Domain, HandleErrors(err, m.Payload.Errors)
if err := client.Mutate(&m, v, WithName("DomainUpdate")); err != nil {
return nil, err
}
if err := m.Payload.Domain.Hydrate(client); err != nil {
return &m.Payload.Domain, err
}
return &m.Payload.Domain, HandleErrors(nil, m.Payload.Errors)
}

func (client *Client) DeleteDomain(identifier string) error {
Expand Down
41 changes: 41 additions & 0 deletions domain_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package opslevel_test

import (
"strings"
"testing"

ol "github.com/opslevel/opslevel-go/v2025"
Expand Down Expand Up @@ -304,3 +305,43 @@ func TestDomainDelete(t *testing.T) {
// Assert
autopilot.Ok(t, err)
}

func TestDomainGetWithHTMLEntities(t *testing.T) {
// Arrange
testRequest := autopilot.NewTestRequest(
`query DomainGet($input:IdentifierInput!){account{domain(input: $input){id,aliases,description,htmlUrl,managedAliases,name,note,owner{... on Team{teamAlias:alias,id}}}}}`,
`{"input": { {{ template "id1" }} } }`,
`{"data": {"account": {"domain": {
{{ template "id1" }},
"aliases": ["test-domain"],
"name": "TestDomain",
"description": "A domain with <html> & special characters "quoted"",
"htmlUrl": "https://app.opslevel.com/catalog/domains/test-domain",
"note": "Note with <b>bold</b> and & ampersand",
"managedAliases": []
}}}}`,
)

client := BestTestClient(t, "domain/html_entities", testRequest)
// Act
result, err := client.GetDomain(string(id1))
// Assert
autopilot.Ok(t, err)

// Verify HTML entities are unescaped
if strings.Contains(result.Description, "<") || strings.Contains(result.Description, ">") ||
strings.Contains(result.Description, "&") || strings.Contains(result.Description, """) {
t.Errorf("Domain Description still contains HTML entities: %s", result.Description)
}
if strings.Contains(result.Note, "<") || strings.Contains(result.Note, ">") ||
strings.Contains(result.Note, "&") {
t.Errorf("Domain Note still contains HTML entities: %s", result.Note)
}

// Verify expected unescaped values
expectedDescription := `A domain with <html> & special characters "quoted"`
expectedNote := "Note with <b>bold</b> and & ampersand"

autopilot.Equals(t, expectedDescription, result.Description)
autopilot.Equals(t, expectedNote, result.Note)
}
5 changes: 5 additions & 0 deletions service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package opslevel
import (
"errors"
"fmt"
"html"
"slices"
"strings"
)
Expand Down Expand Up @@ -115,6 +116,10 @@ func (service *Service) HasTool(category ToolCategory, name string, environment
}

func (service *Service) Hydrate(client *Client) error {
service.Description = html.UnescapeString(service.Description)
service.Note = html.UnescapeString(service.Note)
service.Product = html.UnescapeString(service.Product)

if service.Tags == nil {
service.Tags = &TagConnection{}
}
Expand Down
64 changes: 64 additions & 0 deletions service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package opslevel_test

import (
"fmt"
"strings"
"testing"

ol "github.com/opslevel/opslevel-go/v2025"
Expand Down Expand Up @@ -1669,3 +1670,66 @@ func TestListServicesWithInputFilterCaseSensitivity(t *testing.T) {
autopilot.Ok(t, err)
autopilot.Equals(t, 0, response.TotalCount)
}

func TestServiceGetWithHTMLEntities(t *testing.T) {
// Arrange
testRequest := autopilot.NewTestRequest(
`query ServiceGet($service:ID!){account{service(id: $service){apiDocumentPath,description,framework,htmlUrl,id,aliases,language,lifecycle{alias,description,id,index,name},locked,managedAliases,maturityReport{overallLevel{alias,checks{id,name},description,id,index,name}},name,note,owner{alias,id},parent{id,aliases},preferredApiDocument{id,htmlUrl,source{... on ApiDocIntegration{id,name,type},... on ServiceRepository{baseDirectory,displayName,id,repository{id,defaultAlias},service{id,aliases}}},timestamps{createdAt,updatedAt}},preferredApiDocumentSource,product,repos{edges{node{id,defaultAlias},serviceRepositories{baseDirectory,displayName,id,repository{id,defaultAlias},service{id,aliases}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},defaultServiceRepository{baseDirectory,displayName,id,repository{id,defaultAlias},service{id,aliases}},tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},tier{alias,description,id,index,name},timestamps{createdAt,updatedAt},tools{nodes{category,categoryAlias,displayName,environment,id,service{id,aliases},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},type{id,aliases}}}}`,
`{ "service": "{{ template "id1_string" }}" }`,
`{"data": {"account": {"service": {
{{ template "id1" }},
"aliases": ["test-service"],
"name": "TestService",
"description": "Service with &lt;em&gt;emphasis&lt;/em&gt; &amp; &quot;quotes&quot;",
"htmlUrl": "https://app.opslevel.com/services/test-service",
"note": "Additional notes: &lt;strong&gt;important&lt;/strong&gt; &amp; critical",
"product": "Product with &amp; ampersand",
"apiDocumentPath": null,
"framework": null,
"language": null,
"lifecycle": null,
"locked": false,
"managedAliases": [],
"maturityReport": null,
"owner": null,
"parent": null,
"preferredApiDocument": null,
"preferredApiDocumentSource": null,
"repos": {"edges": [], "pageInfo": {"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}},
"defaultServiceRepository": null,
"tags": {"nodes": [], "pageInfo": {"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}},
"tier": null,
"timestamps": null,
"tools": {"nodes": [], "pageInfo": {"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}},
"type": null
}}}}`,
)

client := BestTestClient(t, "service/html_entities", testRequest)
// Act
result, err := client.GetService(string(id1))
// Assert
autopilot.Ok(t, err)

// Verify HTML entities are unescaped
if strings.Contains(result.Description, "&lt;") || strings.Contains(result.Description, "&gt;") ||
strings.Contains(result.Description, "&amp;") || strings.Contains(result.Description, "&quot;") {
t.Errorf("Service Description still contains HTML entities: %s", result.Description)
}
if strings.Contains(result.Note, "&lt;") || strings.Contains(result.Note, "&gt;") ||
strings.Contains(result.Note, "&amp;") {
t.Errorf("Service Note still contains HTML entities: %s", result.Note)
}
if strings.Contains(result.Product, "&amp;") {
t.Errorf("Service Product still contains HTML entities: %s", result.Product)
}

// Verify expected unescaped values
expectedDescription := `Service with <em>emphasis</em> & "quotes"`
expectedNote := "Additional notes: <strong>important</strong> & critical"
expectedProduct := "Product with & ampersand"

autopilot.Equals(t, expectedDescription, result.Description)
autopilot.Equals(t, expectedNote, result.Note)
autopilot.Equals(t, expectedProduct, result.Product)
}
41 changes: 34 additions & 7 deletions system.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package opslevel
import (
"errors"
"fmt"
"html"
"slices"
)

Expand Down Expand Up @@ -71,6 +72,12 @@ func (system *System) UniqueIdentifiers() []string {
return uniqueIdentifiers
}

func (system *System) Hydrate(client *Client) error {
system.Description = html.UnescapeString(system.Description)
system.Note = html.UnescapeString(system.Note)
return nil
}

func (system *SystemId) ReconcileAliases(client *Client, aliasesWanted []string) error {
aliasesToCreate, aliasesToDelete := extractAliases(system.Aliases, aliasesWanted)

Expand Down Expand Up @@ -139,8 +146,13 @@ func (client *Client) CreateSystem(input SystemInput) (*System, error) {
v := PayloadVariables{
"input": input,
}
err := client.Mutate(&m, v, WithName("SystemCreate"))
return &m.Payload.System, HandleErrors(err, m.Payload.Errors)
if err := client.Mutate(&m, v, WithName("SystemCreate")); err != nil {
return nil, err
}
if err := m.Payload.System.Hydrate(client); err != nil {
return &m.Payload.System, err
}
return &m.Payload.System, HandleErrors(nil, m.Payload.Errors)
}

func (client *Client) GetSystem(identifier string) (*System, error) {
Expand All @@ -152,8 +164,13 @@ func (client *Client) GetSystem(identifier string) (*System, error) {
v := PayloadVariables{
"input": *NewIdentifier(identifier),
}
err := client.Query(&q, v, WithName("SystemGet"))
return &q.Account.System, HandleErrors(err, nil)
if err := client.Query(&q, v, WithName("SystemGet")); err != nil {
return nil, err
}
if err := q.Account.System.Hydrate(client); err != nil {
return &q.Account.System, err
}
return &q.Account.System, nil
}

func (client *Client) ListSystems(variables *PayloadVariables) (*SystemConnection, error) {
Expand All @@ -174,7 +191,12 @@ func (client *Client) ListSystems(variables *PayloadVariables) (*SystemConnectio
if err != nil {
return nil, err
}
q.Account.Systems.Nodes = append(q.Account.Systems.Nodes, resp.Nodes...)
for _, node := range resp.Nodes {
if err := node.Hydrate(client); err != nil {
return nil, err
}
q.Account.Systems.Nodes = append(q.Account.Systems.Nodes, node)
}
q.Account.Systems.PageInfo = resp.PageInfo
}
q.Account.Systems.TotalCount = len(q.Account.Systems.Nodes)
Expand All @@ -189,8 +211,13 @@ func (client *Client) UpdateSystem(identifier string, input SystemInput) (*Syste
"system": *NewIdentifier(identifier),
"input": input,
}
err := client.Mutate(&s, v, WithName("SystemUpdate"))
return &s.Payload.System, HandleErrors(err, s.Payload.Errors)
if err := client.Mutate(&s, v, WithName("SystemUpdate")); err != nil {
return nil, err
}
if err := s.Payload.System.Hydrate(client); err != nil {
return &s.Payload.System, err
}
return &s.Payload.System, HandleErrors(nil, s.Payload.Errors)
}

func (client *Client) DeleteSystem(identifier string) error {
Expand Down
50 changes: 50 additions & 0 deletions system_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package opslevel_test

import (
"strings"
"testing"

ol "github.com/opslevel/opslevel-go/v2025"
Expand Down Expand Up @@ -325,3 +326,52 @@ func TestSystemReconcileAliases(t *testing.T) {
autopilot.Ok(t, err)
autopilot.Equals(t, system.Aliases, aliasesWanted)
}

func TestSystemGetWithHTMLEntities(t *testing.T) {
// Arrange
testRequest := autopilot.NewTestRequest(
`query SystemGet($input:IdentifierInput!){account{system(input: $input){id,aliases,description,htmlUrl,managedAliases,name,note,owner{... on Team{teamAlias:alias,id}},parent{id,aliases,description,htmlUrl,managedAliases,name,note,owner{... on Team{teamAlias:alias,id}}}}}}`,
`{"input": { {{ template "id1" }} } }`,
`{"data": {"account": {"system": {
{{ template "id1" }},
"aliases": ["test-system"],
"name": "TestSystem",
"description": "System with &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt; and &amp; symbol",
"htmlUrl": "https://app.opslevel.com/catalog/systems/test-system",
"note": "Note: &lt;p&gt;paragraph&lt;/p&gt; &amp; more &quot;text&quot;",
"managedAliases": [],
"parent": {
{{ template "id2" }},
"aliases": [],
"name": "ParentDomain",
"description": "",
"htmlUrl": "https://app.opslevel.com/catalog/domains/parent",
"note": "",
"managedAliases": []
}
}}}}`,
)

client := BestTestClient(t, "system/html_entities", testRequest)
// Act
result, err := client.GetSystem(string(id1))
// Assert
autopilot.Ok(t, err)

// Verify HTML entities are unescaped
if strings.Contains(result.Description, "&lt;") || strings.Contains(result.Description, "&gt;") ||
strings.Contains(result.Description, "&amp;") || strings.Contains(result.Description, "&#39;") {
t.Errorf("System Description still contains HTML entities: %s", result.Description)
}
if strings.Contains(result.Note, "&lt;") || strings.Contains(result.Note, "&gt;") ||
strings.Contains(result.Note, "&amp;") || strings.Contains(result.Note, "&quot;") {
t.Errorf("System Note still contains HTML entities: %s", result.Note)
}

// Verify expected unescaped values
expectedDescription := "System with <script>alert('xss')</script> and & symbol"
expectedNote := `Note: <p>paragraph</p> & more "text"`

autopilot.Equals(t, expectedDescription, result.Description)
autopilot.Equals(t, expectedNote, result.Note)
}
Loading