From ce4e418d84d471ee3bd20c4414ddee97e6cd4f72 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 2 Apr 2026 23:26:13 +0100 Subject: [PATCH] Add hosted mobile token revoke helper --- .../bootstrap-hosted-mobile-onboarding.mjs | 56 +------ .../scripts/delete-hosted-mobile-token.mjs | 95 ++++++++++++ .../scripts/hosted-mobile-token-runtime.mjs | 103 +++++++++++++ .../scripts/relay-mobile-token-helper.go | 142 ++++++++++++++++-- .../scripts/relay-mobile-token-helper_test.go | 54 +++++++ 5 files changed, 380 insertions(+), 70 deletions(-) create mode 100644 tests/integration/scripts/delete-hosted-mobile-token.mjs create mode 100644 tests/integration/scripts/hosted-mobile-token-runtime.mjs create mode 100644 tests/integration/scripts/relay-mobile-token-helper_test.go diff --git a/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs b/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs index 2bbbdc621..7438f5ec2 100644 --- a/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs +++ b/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs @@ -7,11 +7,9 @@ import path from 'node:path'; import process from 'node:process'; import { - resolveHostedTenantRootDataDir, restartHostedTenantRuntime, - runRemote, - shellQuote, } from './hosted-tenant-runtime.mjs'; +import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs'; const DEFAULT_CLOUD_HOST = 'root@pulse-cloud'; const DEFAULT_CONTROL_PLANE_URL = 'https://cloud.pulserelay.pro'; @@ -106,28 +104,6 @@ function runText(command, args, options = {}) { }); } -function buildLocalHelper(tempDir) { - const binaryPath = path.join(tempDir, 'relay-mobile-token-helper'); - execFileSync('go', [ - 'build', - '-buildvcs=false', - '-o', - binaryPath, - './tests/integration/scripts/relay-mobile-token-helper.go', - ], { - cwd: REPO_ROOT, - encoding: 'utf8', - env: { - ...process.env, - CGO_ENABLED: '0', - GOARCH: 'amd64', - GOOS: 'linux', - }, - stdio: 'pipe', - }); - return binaryPath; -} - function deriveTenantBaseUrl(controlPlaneUrl, tenantId) { const parsed = new URL(controlPlaneUrl); parsed.hostname = `${tenantId}.${parsed.hostname}`; @@ -137,36 +113,6 @@ function deriveTenantBaseUrl(controlPlaneUrl, tenantId) { return parsed.toString().replace(/\/$/, ''); } -function createHostedRelayMobileToken({ cloudHost, tenantId, tempDir }) { - const localBinaryPath = buildLocalHelper(tempDir); - const remoteBinaryPath = `/tmp/relay-mobile-token-helper-${process.pid}-${Date.now()}`; - const tenantDataDir = resolveHostedTenantRootDataDir(tenantId); - - try { - execFileSync('scp', [localBinaryPath, `${cloudHost}:${remoteBinaryPath}`], { - encoding: 'utf8', - maxBuffer: 32 * 1024 * 1024, - stdio: 'pipe', - }); - runRemote(cloudHost, `chmod +x ${shellQuote(remoteBinaryPath)}`); - - const output = runRemote(cloudHost, [ - shellQuote(remoteBinaryPath), - 'create', - '--data-dir', - shellQuote(tenantDataDir), - '--org-id', - shellQuote(tenantId), - ].join(' ')); - - return JSON.parse(output); - } finally { - try { - runRemote(cloudHost, `rm -f ${shellQuote(remoteBinaryPath)}`); - } catch {} - } -} - function curlJson(args) { return JSON.parse(runText('curl', args)); } diff --git a/tests/integration/scripts/delete-hosted-mobile-token.mjs b/tests/integration/scripts/delete-hosted-mobile-token.mjs new file mode 100644 index 000000000..d7734bd09 --- /dev/null +++ b/tests/integration/scripts/delete-hosted-mobile-token.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import { deleteHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs'; +import { restartHostedTenantRuntime } from './hosted-tenant-runtime.mjs'; + +const DEFAULT_CLOUD_HOST = 'root@pulse-cloud'; + +function usage(message) { + if (message) { + console.error(`error: ${message}`); + console.error(''); + } + + console.error( + 'usage: node ./tests/integration/scripts/delete-hosted-mobile-token.mjs --tenant-id [--token-id ] [--token ] [--cloud-host ]', + ); + process.exit(1); +} + +function parseArgs(argv) { + const parsed = { + cloudHost: DEFAULT_CLOUD_HOST, + tenantId: '', + token: '', + tokenId: '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--tenant-id': + parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id'); + index += 1; + break; + case '--token-id': + parsed.tokenId = argv[index + 1] ?? usage('missing value for --token-id'); + index += 1; + break; + case '--token': + parsed.token = argv[index + 1] ?? usage('missing value for --token'); + index += 1; + break; + case '--cloud-host': + parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host'); + index += 1; + break; + case '--help': + case '-h': + usage(); + break; + default: + usage(`unsupported flag ${arg}`); + } + } + + parsed.tenantId = String(parsed.tenantId).trim(); + parsed.tokenId = String(parsed.tokenId).trim(); + parsed.token = String(parsed.token).trim(); + + if (!parsed.tenantId) { + usage('--tenant-id is required'); + } + if (!parsed.tokenId && !parsed.token) { + usage('either --token-id or --token is required'); + } + + return parsed; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-token-delete-')); + + try { + const result = deleteHostedRelayMobileToken({ + cloudHost: args.cloudHost, + tenantId: args.tenantId, + tempDir, + token: args.token || null, + tokenId: args.tokenId || null, + }); + restartHostedTenantRuntime(args.cloudHost, args.tenantId); + result.runtimeRestarted = true; + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } +} + +main(); diff --git a/tests/integration/scripts/hosted-mobile-token-runtime.mjs b/tests/integration/scripts/hosted-mobile-token-runtime.mjs new file mode 100644 index 000000000..bc87230a7 --- /dev/null +++ b/tests/integration/scripts/hosted-mobile-token-runtime.mjs @@ -0,0 +1,103 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { + resolveHostedTenantRootDataDir, + runRemote, + shellQuote, +} from './hosted-tenant-runtime.mjs'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +function buildLocalHelper(tempDir) { + const binaryPath = path.join(tempDir, 'relay-mobile-token-helper'); + execFileSync('go', [ + 'build', + '-buildvcs=false', + '-o', + binaryPath, + './tests/integration/scripts/relay-mobile-token-helper.go', + ], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { + ...process.env, + CGO_ENABLED: '0', + GOARCH: 'amd64', + GOOS: 'linux', + }, + stdio: 'pipe', + }); + return binaryPath; +} + +function runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }) { + const localBinaryPath = buildLocalHelper(tempDir); + const remoteBinaryPath = `/tmp/relay-mobile-token-helper-${process.pid}-${Date.now()}`; + + try { + execFileSync('scp', [localBinaryPath, `${cloudHost}:${remoteBinaryPath}`], { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + stdio: 'pipe', + }); + runRemote(cloudHost, `chmod +x ${shellQuote(remoteBinaryPath)}`); + + return runRemote(cloudHost, [ + shellQuote(remoteBinaryPath), + ...args.map((value) => shellQuote(value)), + ].join(' ')); + } finally { + try { + runRemote(cloudHost, `rm -f ${shellQuote(remoteBinaryPath)}`); + } catch {} + } +} + +export function createHostedRelayMobileToken({ cloudHost, issuedVia = null, tenantId, tempDir }) { + const args = [ + 'create', + '--data-dir', + resolveHostedTenantRootDataDir(tenantId), + '--org-id', + tenantId, + ]; + if (issuedVia) { + args.push('--issued-via', issuedVia); + } + return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir })); +} + +export function deleteHostedRelayMobileToken({ + cloudHost, + tenantId, + tempDir, + token = null, + tokenId = null, +}) { + const args = [ + 'delete', + '--data-dir', + resolveHostedTenantRootDataDir(tenantId), + ]; + if (tokenId) { + args.push('--token-id', tokenId); + } + if (token) { + args.push('--token', token); + } + return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir })); +} + +export function validateHostedRelayMobileToken({ cloudHost, tenantId, tempDir, token }) { + const args = [ + 'validate', + '--data-dir', + resolveHostedTenantRootDataDir(tenantId), + '--token', + token, + ]; + return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir })); +} diff --git a/tests/integration/scripts/relay-mobile-token-helper.go b/tests/integration/scripts/relay-mobile-token-helper.go index 0c4bb2504..5c4a9d4f9 100644 --- a/tests/integration/scripts/relay-mobile-token-helper.go +++ b/tests/integration/scripts/relay-mobile-token-helper.go @@ -37,6 +37,13 @@ type validationResult struct { Record *helperTokenRecord `json:"record,omitempty"` } +type deleteResult struct { + Action string `json:"action"` + DataDir string `json:"dataDir"` + Deleted bool `json:"deleted"` + Record *helperTokenRecord `json:"record,omitempty"` +} + type helperTokenRecord struct { ID string `json:"id"` Metadata map[string]string `json:"metadata,omitempty"` @@ -56,6 +63,8 @@ func usage(message string) { } fmt.Fprintln(os.Stderr, "usage:") fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go create --data-dir --org-id [options]") + fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go delete --data-dir (--token-id | --token )") + fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go validate --data-dir --token ") os.Exit(1) } @@ -87,6 +96,60 @@ func pruneExistingProofTokens(tokens []config.APITokenRecord, orgID, issuedVia s return filtered, pruned } +func toHelperTokenRecord(record config.APITokenRecord) helperTokenRecord { + return helperTokenRecord{ + ID: record.ID, + Metadata: record.Metadata, + Name: record.Name, + OrgID: record.OrgID, + Scopes: append([]string{}, record.Scopes...), + } +} + +func findTokenRecord(tokens []config.APITokenRecord, tokenID, rawToken string) (*config.APITokenRecord, error) { + scopedTokenID := strings.TrimSpace(tokenID) + scopedRawToken := strings.TrimSpace(rawToken) + + if scopedTokenID == "" && scopedRawToken == "" { + return nil, fmt.Errorf("either token id or raw token is required") + } + + if scopedRawToken != "" { + cfg := &config.Config{APITokens: tokens} + record, ok := cfg.ValidateAPIToken(scopedRawToken) + if !ok || record == nil { + return nil, nil + } + scopedTokenID = strings.TrimSpace(record.ID) + } + + for idx := range tokens { + if strings.TrimSpace(tokens[idx].ID) == scopedTokenID { + return &tokens[idx], nil + } + } + + return nil, nil +} + +func deleteTokenRecord(tokens []config.APITokenRecord, tokenID string) ([]config.APITokenRecord, *config.APITokenRecord) { + scopedTokenID := strings.TrimSpace(tokenID) + filtered := make([]config.APITokenRecord, 0, len(tokens)) + var removed *config.APITokenRecord + + for idx := range tokens { + record := tokens[idx] + if removed == nil && strings.TrimSpace(record.ID) == scopedTokenID { + recordCopy := record + removed = &recordCopy + continue + } + filtered = append(filtered, record) + } + + return filtered, removed +} + func createRelayMobileToken(args []string) { flags := flag.NewFlagSet("create", flag.ExitOnError) dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json") @@ -152,19 +215,72 @@ func createRelayMobileToken(args []string) { DataDir: scopedDataDir, OrgID: scopedOrgID, PrunedCount: prunedCount, - Record: helperTokenRecord{ - ID: record.ID, - Metadata: record.Metadata, - Name: record.Name, - OrgID: record.OrgID, - Scopes: append([]string{}, record.Scopes...), - }, - Token: rawToken, + Record: toHelperTokenRecord(*record), + Token: rawToken, }); err != nil { fatalf("encode result: %v", err) } } +func deleteRelayMobileToken(args []string) { + flags := flag.NewFlagSet("delete", flag.ExitOnError) + dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json") + tokenID := flags.String("token-id", "", "Token record id to delete") + rawToken := flags.String("token", "", "Raw token value to resolve and delete") + if err := flags.Parse(args); err != nil { + fatalf("%v", err) + } + + scopedDataDir := strings.TrimSpace(*dataDir) + if scopedDataDir == "" { + usage("--data-dir is required") + } + + persistence := config.NewConfigPersistence(scopedDataDir) + tokens, err := persistence.LoadAPITokens() + if err != nil { + fatalf("load api tokens: %v", err) + } + + record, err := findTokenRecord(tokens, *tokenID, *rawToken) + if err != nil { + fatalf("resolve token: %v", err) + } + if record == nil { + fatalf("token not found") + } + + filteredTokens, removed := deleteTokenRecord(tokens, record.ID) + if removed == nil { + fatalf("token not found") + } + + cfg := &config.Config{APITokens: filteredTokens} + cfg.SortAPITokens() + if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { + fatalf("persist api tokens: %v", err) + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(deleteResult{ + Action: "delete", + DataDir: scopedDataDir, + Deleted: true, + Record: ptrToHelperTokenRecord(removed), + }); err != nil { + fatalf("encode result: %v", err) + } +} + +func ptrToHelperTokenRecord(record *config.APITokenRecord) *helperTokenRecord { + if record == nil { + return nil + } + converted := toHelperTokenRecord(*record) + return &converted +} + func validateRelayMobileToken(args []string) { flags := flag.NewFlagSet("validate", flag.ExitOnError) dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json") @@ -196,13 +312,7 @@ func validateRelayMobileToken(args []string) { Found: ok && record != nil, } if ok && record != nil { - result.Record = &helperTokenRecord{ - ID: record.ID, - Metadata: record.Metadata, - Name: record.Name, - OrgID: record.OrgID, - Scopes: append([]string{}, record.Scopes...), - } + result.Record = ptrToHelperTokenRecord(record) } encoder := json.NewEncoder(os.Stdout) @@ -222,6 +332,8 @@ func main() { switch strings.ToLower(strings.TrimSpace(os.Args[1])) { case "create": createRelayMobileToken(os.Args[2:]) + case "delete": + deleteRelayMobileToken(os.Args[2:]) case "validate": validateRelayMobileToken(os.Args[2:]) case "--help", "-h", "help": diff --git a/tests/integration/scripts/relay-mobile-token-helper_test.go b/tests/integration/scripts/relay-mobile-token-helper_test.go new file mode 100644 index 000000000..1c5f19b8f --- /dev/null +++ b/tests/integration/scripts/relay-mobile-token-helper_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestFindTokenRecordResolvesRawToken(t *testing.T) { + rawToken, err := config.NewAPITokenRecord("relay-mobile-raw-token-123.12345678", "Pulse Mobile", []string{config.ScopeRelayMobileAccess}) + if err != nil { + t.Fatalf("NewAPITokenRecord: %v", err) + } + otherToken, err := config.NewAPITokenRecord("other-raw-token-123.12345678", "Other", []string{config.ScopeRelayMobileAccess}) + if err != nil { + t.Fatalf("NewAPITokenRecord other: %v", err) + } + + record, err := findTokenRecord([]config.APITokenRecord{*rawToken, *otherToken}, "", "relay-mobile-raw-token-123.12345678") + if err != nil { + t.Fatalf("findTokenRecord: %v", err) + } + if record == nil { + t.Fatal("expected record, got nil") + } + if record.ID != rawToken.ID { + t.Fatalf("record ID = %q, want %q", record.ID, rawToken.ID) + } +} + +func TestDeleteTokenRecordRemovesOnlyMatchingToken(t *testing.T) { + first, err := config.NewAPITokenRecord("relay-mobile-delete-1.12345678", "First", []string{config.ScopeRelayMobileAccess}) + if err != nil { + t.Fatalf("NewAPITokenRecord first: %v", err) + } + second, err := config.NewAPITokenRecord("relay-mobile-delete-2.12345678", "Second", []string{config.ScopeRelayMobileAccess}) + if err != nil { + t.Fatalf("NewAPITokenRecord second: %v", err) + } + + filtered, removed := deleteTokenRecord([]config.APITokenRecord{*first, *second}, second.ID) + if removed == nil { + t.Fatal("expected removed token, got nil") + } + if removed.ID != second.ID { + t.Fatalf("removed ID = %q, want %q", removed.ID, second.ID) + } + if len(filtered) != 1 { + t.Fatalf("filtered len = %d, want 1", len(filtered)) + } + if filtered[0].ID != first.ID { + t.Fatalf("remaining ID = %q, want %q", filtered[0].ID, first.ID) + } +}