diff --git a/.github/workflows/api-tests.yaml b/.github/workflows/api-tests.yaml index 7381b9323..96e3b1da2 100644 --- a/.github/workflows/api-tests.yaml +++ b/.github/workflows/api-tests.yaml @@ -193,6 +193,8 @@ jobs: base-url: ${{ env.USERS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --exclude-operation-id=requestPasswordReset --phases=examples' + coverage-artifact-name: schema-coverage-users + coverage-pr-comment: false - name: Run Groups API tests if: (steps.changes.outputs.groups == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('groups/api/http/**') != '' @@ -202,6 +204,8 @@ jobs: base-url: ${{ env.GROUPS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-groups + coverage-pr-comment: false - name: Run Clients API tests if: (steps.changes.outputs.clients == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('clients/api/http/**') != '' @@ -211,6 +215,8 @@ jobs: base-url: ${{ env.CLIENTS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-clients + coverage-pr-comment: false - name: Run Channels API tests if: (steps.changes.outputs.channels == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('channels/api/http/**') != '' @@ -220,6 +226,8 @@ jobs: base-url: ${{ env.CHANNELS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-channels + coverage-pr-comment: false - name: Run Domains API tests if: (steps.changes.outputs.domains == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('domains/api/http/**') != '' @@ -229,6 +237,8 @@ jobs: base-url: ${{ env.DOMAIN_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-domains + coverage-pr-comment: false - name: Run Journal API tests if: steps.changes.outputs.journal == 'true' || steps.changes.outputs.workflow == 'true' @@ -238,6 +248,8 @@ jobs: base-url: ${{ env.JOURNAL_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-journal + coverage-pr-comment: false - name: Run Bootstrap API tests if: (steps.changes.outputs.bootstrap == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('bootstrap/api/**') != '' @@ -247,6 +259,8 @@ jobs: base-url: ${{ env.BOOTSTRAP_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-bootstrap + coverage-pr-comment: false - name: Run Certs API tests if: (steps.changes.outputs.certs == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('docker/addons/certs/docker-compose.yaml') != '' @@ -256,6 +270,8 @@ jobs: base-url: ${{ env.CERTS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-certs + coverage-pr-comment: false - name: Run Readers API tests if: steps.changes.outputs.readers == 'true' || steps.changes.outputs.workflow == 'true' @@ -265,6 +281,8 @@ jobs: base-url: ${{ env.READERS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-readers + coverage-pr-comment: false - name: Run Rules Engine API tests if: steps.changes.outputs.re == 'true' || steps.changes.outputs.workflow == 'true' @@ -274,6 +292,8 @@ jobs: base-url: ${{ env.RE_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-rules + coverage-pr-comment: false - name: Run Alarms API tests if: steps.changes.outputs.alarms == 'true' || steps.changes.outputs.workflow == 'true' @@ -283,6 +303,8 @@ jobs: base-url: ${{ env.ALARMS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-alarms + coverage-pr-comment: false - name: Run Reports API tests if: steps.changes.outputs.reports == 'true' || steps.changes.outputs.workflow == 'true' @@ -292,6 +314,8 @@ jobs: base-url: ${{ env.REPORTS_URL }} checks: all args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples' + coverage-artifact-name: schema-coverage-reports + coverage-pr-comment: false - name: Stop containers if: always() diff --git a/cmd/fluxmq/main.go b/cmd/fluxmq/main.go index f5aa53f20..1fa4aac37 100644 --- a/cmd/fluxmq/main.go +++ b/cmd/fluxmq/main.go @@ -9,7 +9,6 @@ package main import ( "context" - "errors" "fmt" "log" "net/http" @@ -54,40 +53,6 @@ type config struct { InstanceID string `env:"MG_FLUXMQ_INSTANCE_ID" envDefault:""` } -type fanoutPublisher struct { - publishers []messaging.Publisher -} - -func (fp fanoutPublisher) Publish(ctx context.Context, topic string, msg *messaging.Message) error { - for _, publisher := range fp.publishers { - if err := publisher.Publish(ctx, topic, msg); err != nil { - return err - } - } - return nil -} - -func (fp fanoutPublisher) Close() error { - errs := make([]error, 0, len(fp.publishers)) - for _, publisher := range fp.publishers { - errs = append(errs, publisher.Close()) - } - return errors.Join(errs...) -} - -type writerBridgeHandler struct { - ctx context.Context - publisher messaging.Publisher -} - -func (h writerBridgeHandler) Handle(msg *messaging.Message) error { - return h.publisher.Publish(h.ctx, messaging.EncodeMessageTopic(msg), msg) -} - -func (h writerBridgeHandler) Cancel() error { - return nil -} - func main() { ctx, cancel := context.WithCancel(context.Background()) g, ctx := errgroup.WithContext(ctx) @@ -200,45 +165,6 @@ func main() { } defer messagePublisher.Close() - writerPublisher, err := fluxmqbroker.NewUndeclaredPublisher( - ctx, - cfg.BrokerURL, - fluxmqbroker.Prefix("writers"), - fluxmqbroker.ConnectionName("fluxmq-ui-publish-proxy"), - ) - if err != nil { - logger.Error(fmt.Sprintf("failed to create publish proxy writer publisher: %s", err)) - exitCode = 1 - return - } - defer writerPublisher.Close() - publisher := fanoutPublisher{publishers: []messaging.Publisher{messagePublisher, writerPublisher}} - - writerBridge, err := fluxmqbroker.NewPubSub( - ctx, - cfg.BrokerURL, - logger, - fluxmqbroker.DirectTopicOnly(), - fluxmqbroker.ConnectionName("fluxmq-mqtt-writer-bridge"), - ) - if err != nil { - logger.Error(fmt.Sprintf("failed to create MQTT writer bridge subscriber: %s", err)) - exitCode = 1 - return - } - defer writerBridge.Close() - if err := writerBridge.Subscribe(ctx, messaging.SubscriberConfig{ - ID: cfg.InstanceID + "-mqtt-writer-bridge", - Topic: "m/#", - Handler: writerBridgeHandler{ctx: ctx, publisher: writerPublisher}, - DeliveryPolicy: messaging.DeliverNewPolicy, - }); err != nil { - logger.Error(fmt.Sprintf("failed to subscribe MQTT writer bridge: %s", err)) - exitCode = 1 - return - } - logger.Info("FluxMQ MQTT writer bridge subscribed", "topic", "m/#") - httpServerConfig := server.Config{Port: "9026"} if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil { logger.Error(fmt.Sprintf("failed to load publish proxy HTTP server configuration: %s", err)) @@ -250,7 +176,7 @@ func main() { cancel, "fluxmq-publish", httpServerConfig, - fluxmqhttp.MakePublishHandler(authn, atomAuthz, publisher), + fluxmqhttp.MakePublishHandler(authn, atomAuthz, messagePublisher), logger, ) diff --git a/internal/atom/client.go b/internal/atom/client.go index 7078db5b5..9a6f1ea38 100644 --- a/internal/atom/client.go +++ b/internal/atom/client.go @@ -56,7 +56,7 @@ func (c *Client) CreateTenant(ctx context.Context, tenant Tenant) (Tenant, error CreateTenant Tenant `json:"createTenant"` } err := c.graphQL(ctx, `mutation CreateTenant($input: CreateTenantInput!) { - createTenant(input: $input) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } + createTenant(input: $input) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } }`, map[string]any{"input": tenantCreateInput(tenant)}, &out) return out.CreateTenant, err } @@ -66,7 +66,7 @@ func (c *Client) GetTenant(ctx context.Context, id string) (Tenant, error) { Tenant Tenant `json:"tenant"` } err := c.graphQL(ctx, `query Tenant($id: ID!) { - tenant(id: $id) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } + tenant(id: $id) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } }`, map[string]any{"id": id}, &out) return out.Tenant, err } @@ -76,7 +76,7 @@ func (c *Client) UpdateTenant(ctx context.Context, id string, tenant Tenant) (Te UpdateTenant Tenant `json:"updateTenant"` } err := c.graphQL(ctx, `mutation UpdateTenant($id: ID!, $input: UpdateTenantInput!) { - updateTenant(id: $id, input: $input) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } + updateTenant(id: $id, input: $input) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } }`, map[string]any{"id": id, "input": tenantUpdateInput(tenant)}, &out) return out.UpdateTenant, err } @@ -92,7 +92,7 @@ func (c *Client) ChangeTenantStatus(ctx context.Context, id, action string) (Ten } var out map[string]Tenant err := c.graphQL(ctx, fmt.Sprintf(`mutation ChangeTenantStatus($id: ID!) { - %s(id: $id) { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } + %s(id: $id) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } }`, field), map[string]any{"id": id}, &out) if err != nil { return Tenant{}, err @@ -234,10 +234,10 @@ func (c *Client) ListTenants(ctx context.Context, q Query) (TenantList, error) { var out struct { Tenants TenantList `json:"tenants"` } - err := c.graphQL(ctx, `query Tenants($q: String, $name: String, $route: String, $status: TenantStatus, $limit: Int, $offset: Int) { - tenants(q: $q, name: $name, route: $route, status: $status, limit: $limit, offset: $offset) { + err := c.graphQL(ctx, `query Tenants($q: String, $name: String, $alias: String, $status: TenantStatus, $limit: Int, $offset: Int) { + tenants(q: $q, name: $name, alias: $alias, status: $status, limit: $limit, offset: $offset) { total - items { id name route status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } + items { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt } } }`, queryVariables(q), &out) return out.Tenants, err @@ -506,6 +506,64 @@ func (c *Client) CreateAPIKey(ctx context.Context, entityID, description string) return out.CreateAPIKey, err } +func (c *Client) CreateSharedKey(ctx context.Context, entityID, key, description string) (SharedKeyResponse, error) { + var out struct { + CreateSharedKey SharedKeyResponse `json:"createSharedKey"` + } + input := map[string]any{} + setIfNotEmpty(input, "key", key) + setIfNotEmpty(input, "description", description) + err := c.graphQL(ctx, `mutation CreateSharedKey($entityId: ID!, $input: CreateSharedKeyInput!) { + createSharedKey(entityId: $entityId, input: $input) { + credentialId + key + expiresAt + } + }`, map[string]any{ + "entityId": entityID, + "input": input, + }, &out) + return out.CreateSharedKey, err +} + +func (c *Client) RevealSharedKey(ctx context.Context, entityID, credentialID string) (SharedKeyResponse, error) { + var out struct { + RevealSharedKey SharedKeyResponse `json:"revealSharedKey"` + } + err := c.graphQL(ctx, `mutation RevealSharedKey($entityId: ID!, $credentialId: ID!) { + revealSharedKey(entityId: $entityId, credentialId: $credentialId) { + credentialId + key + expiresAt + } + }`, map[string]any{ + "entityId": entityID, + "credentialId": credentialID, + }, &out) + return out.RevealSharedKey, err +} + +func (c *Client) ListCredentials(ctx context.Context, entityID string) (CredentialList, error) { + var out struct { + Credentials CredentialList `json:"credentials"` + } + err := c.graphQL(ctx, `query Credentials($entityId: ID!) { + credentials(entityId: $entityId) { + total + items { + id + entity_id: entityId + kind + identifier + status + expires_at: expiresAt + created_at: createdAt + } + } + }`, map[string]any{"entityId": entityID}, &out) + return out.Credentials, err +} + func (c *Client) RevokeCredential(ctx context.Context, entityID, credentialID string) error { return c.graphQL(ctx, `mutation RevokeCredential($entityId: ID!, $credentialId: ID!) { revokeCredential(entityId: $entityId, credentialId: $credentialId) @@ -632,7 +690,7 @@ func graphQLErr(errors []graphQLErrorItem) error { func tenantCreateInput(tenant Tenant) map[string]any { input := map[string]any{"name": tenant.Name} setIfNotEmpty(input, "id", tenant.ID) - setIfNotEmpty(input, "route", tenant.Route) + setIfNotEmpty(input, "alias", tenant.Route) if tenant.Tags != nil { input["tags"] = tenant.Tags } @@ -645,7 +703,7 @@ func tenantCreateInput(tenant Tenant) map[string]any { func tenantUpdateInput(tenant Tenant) map[string]any { input := map[string]any{} setIfNotEmpty(input, "name", tenant.Name) - setIfNotEmpty(input, "route", tenant.Route) + setIfNotEmpty(input, "alias", tenant.Route) if tenant.Tags != nil { input["tags"] = tenant.Tags } @@ -798,7 +856,7 @@ func queryVariables(q Query) map[string]any { vars := map[string]any{} setIfNotEmpty(vars, "q", q.Q) setIfNotEmpty(vars, "name", q.Name) - setIfNotEmpty(vars, "route", q.Route) + setIfNotEmpty(vars, "alias", q.Route) setIfNotEmpty(vars, "kind", q.Kind) setIfNotEmpty(vars, "tenantId", q.TenantID) setIfNotEmpty(vars, "status", q.Status) diff --git a/internal/atom/client_test.go b/internal/atom/client_test.go index 67fd50f08..ec3ea45c1 100644 --- a/internal/atom/client_test.go +++ b/internal/atom/client_test.go @@ -94,6 +94,291 @@ func TestListResources(t *testing.T) { } } +func TestCreateTenantMapsRouteToAlias(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if !strings.Contains(payload.Query, "createTenant") || !strings.Contains(payload.Query, "route: alias") { + t.Fatalf("query does not map tenant alias to route: %s", payload.Query) + } + input, ok := payload.Variables["input"].(map[string]any) + if !ok { + t.Fatalf("unexpected input: %+v", payload.Variables["input"]) + } + if input["alias"] != "d1" { + t.Fatalf("expected alias input from route, got: %+v", input) + } + if _, ok := input["route"]; ok { + t.Fatalf("input must not use Atom route field: %+v", input) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "createTenant": map[string]any{ + "id": testTenantID, + "name": "D1", + "route": "d1", + "status": "active", + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + got, err := client.CreateTenant(context.Background(), Tenant{Name: "D1", Route: "d1"}) + if err != nil { + t.Fatalf("create tenant failed: %v", err) + } + if got.ID != testTenantID || got.Route != "d1" { + t.Fatalf("unexpected tenant: %+v", got) + } +} + +func TestUpdateTenantMapsRouteToAlias(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if !strings.Contains(payload.Query, "updateTenant") || !strings.Contains(payload.Query, "route: alias") { + t.Fatalf("query does not map tenant alias to route: %s", payload.Query) + } + if payload.Variables["id"] != testTenantID { + t.Fatalf("unexpected variables: %+v", payload.Variables) + } + input, ok := payload.Variables["input"].(map[string]any) + if !ok { + t.Fatalf("unexpected input: %+v", payload.Variables["input"]) + } + if input["alias"] != "d1" { + t.Fatalf("expected alias input from route, got: %+v", input) + } + if _, ok := input["route"]; ok { + t.Fatalf("input must not use Atom route field: %+v", input) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "updateTenant": map[string]any{ + "id": testTenantID, + "name": "D1", + "route": "d1", + "status": "active", + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + got, err := client.UpdateTenant(context.Background(), testTenantID, Tenant{Name: "D1", Route: "d1"}) + if err != nil { + t.Fatalf("update tenant failed: %v", err) + } + if got.ID != testTenantID || got.Route != "d1" { + t.Fatalf("unexpected tenant: %+v", got) + } +} + +func TestListTenantsMapsRouteToAlias(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if !strings.Contains(payload.Query, "$alias: String") || + !strings.Contains(payload.Query, "alias: $alias") || + !strings.Contains(payload.Query, "route: alias") { + t.Fatalf("query does not use Atom alias for tenant route lookup: %s", payload.Query) + } + if strings.Contains(payload.Query, "$route") || strings.Contains(payload.Query, "route: $route") { + t.Fatalf("query must not use removed Atom route field/filter: %s", payload.Query) + } + if payload.Variables["alias"] != "d1" { + t.Fatalf("expected alias variable from route, got: %+v", payload.Variables) + } + if _, ok := payload.Variables["route"]; ok { + t.Fatalf("variables must not use Atom route field: %+v", payload.Variables) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "tenants": map[string]any{ + "items": []Tenant{{ID: testTenantID, Name: "D1", Route: "d1", Status: "active"}}, + "total": 1, + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + got, err := client.ListTenants(context.Background(), Query{Route: "d1", Limit: 1}) + if err != nil { + t.Fatalf("list tenants failed: %v", err) + } + if got.Total != 1 || len(got.Items) != 1 || got.Items[0].Route != "d1" { + t.Fatalf("unexpected tenants: %+v", got) + } +} + +func TestCreateSharedKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if !strings.Contains(payload.Query, "createSharedKey") { + t.Fatalf("query does not create shared key: %s", payload.Query) + } + if payload.Variables["entityId"] != testDeviceID { + t.Fatalf("unexpected entity id: %+v", payload.Variables) + } + input, ok := payload.Variables["input"].(map[string]any) + if !ok { + t.Fatalf("unexpected input: %+v", payload.Variables["input"]) + } + if input["key"] != testClientKey || input["description"] != "provisioned from mg" { + t.Fatalf("unexpected input: %+v", input) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "createSharedKey": map[string]any{ + "credentialId": testCredentialID, + "key": testClientKey, + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + got, err := client.CreateSharedKey(context.Background(), testDeviceID, testClientKey, "provisioned from mg") + if err != nil { + t.Fatalf("create shared key failed: %v", err) + } + if got.CredentialID != testCredentialID || got.Key != testClientKey { + t.Fatalf("unexpected shared key response: %+v", got) + } +} + +func TestRevealSharedKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if !strings.Contains(payload.Query, "revealSharedKey") { + t.Fatalf("query does not reveal shared key: %s", payload.Query) + } + if payload.Variables["entityId"] != testDeviceID || payload.Variables["credentialId"] != testCredentialID { + t.Fatalf("unexpected variables: %+v", payload.Variables) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "revealSharedKey": map[string]any{ + "credentialId": testCredentialID, + "key": testClientKey, + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + got, err := client.RevealSharedKey(context.Background(), testDeviceID, testCredentialID) + if err != nil { + t.Fatalf("reveal shared key failed: %v", err) + } + if got.CredentialID != testCredentialID || got.Key != testClientKey { + t.Fatalf("unexpected shared key response: %+v", got) + } +} + +func TestListCredentials(t *testing.T) { + createdAt := "2026-06-30T10:15:30Z" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != atomGraphQLPath { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if !strings.Contains(payload.Query, "credentials") { + t.Fatalf("query does not list credentials: %s", payload.Query) + } + if payload.Variables["entityId"] != testDeviceID { + t.Fatalf("unexpected variables: %+v", payload.Variables) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "credentials": map[string]any{ + "total": 1, + "items": []map[string]any{{ + "id": testCredentialID, + "entity_id": testDeviceID, + "kind": "shared_key", + "identifier": "", + "status": "active", + "created_at": createdAt, + }}, + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + got, err := client.ListCredentials(context.Background(), testDeviceID) + if err != nil { + t.Fatalf("list credentials failed: %v", err) + } + if got.Total != 1 || len(got.Items) != 1 { + t.Fatalf("unexpected credentials response: %+v", got) + } + item := got.Items[0] + if item.ID != testCredentialID || item.EntityID != testDeviceID || item.Kind != "shared_key" || item.Status != "active" { + t.Fatalf("unexpected credential item: %+v", item) + } + if item.CreatedAt.Format(time.RFC3339) != createdAt { + t.Fatalf("unexpected created_at: %s", item.CreatedAt.Format(time.RFC3339)) + } +} + func TestLoadConfig(t *testing.T) { t.Setenv("ATOM_URL", "http://atom:8080/") t.Setenv("ATOM_ADMIN_TOKEN", "token") diff --git a/internal/atom/test_constants_test.go b/internal/atom/test_constants_test.go index 23f937d1c..34f1dd34a 100644 --- a/internal/atom/test_constants_test.go +++ b/internal/atom/test_constants_test.go @@ -6,5 +6,9 @@ package atom const ( testDeviceSecret = "device-secret" testDomainID = "domain-1" + testTenantID = "tenant-1" testEntityID = "entity-1" + testDeviceID = "device-1" + testClientKey = "client-key" + testCredentialID = "cred-1" ) diff --git a/internal/atom/types.go b/internal/atom/types.go index 179f16f37..d11b7ea08 100644 --- a/internal/atom/types.go +++ b/internal/atom/types.go @@ -238,6 +238,27 @@ type APIKeyResponse struct { ExpiresAt *time.Time `json:"expiresAt,omitempty"` } +type SharedKeyResponse struct { + CredentialID string `json:"credentialId"` + Key string `json:"key"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +type Credential struct { + ID string `json:"id"` + EntityID string `json:"entity_id,omitempty"` + Kind string `json:"kind"` + Identifier string `json:"identifier,omitempty"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +type CredentialList struct { + Items []Credential `json:"items"` + Total uint64 `json:"total"` +} + type ResourceList struct { Items []Resource `json:"items"` Total uint64 `json:"total"`