From 1572ac551d3512d32701d8293e91dd16da2f5b38 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:12:57 +0000 Subject: [PATCH] test: add golden parity tests for the v1 API Pin every endpoint's behaviour with golden fixtures captured from the retiring grpc-gateway (timestamps and secrets neutralised). Error cases assert their HTTP status independently of the golden via assertStatus, so a blind regenerate cannot re-pin a wrong code; HEADSCALE_UPDATE_GOLDEN rewrites the fixtures. A unit test exercises the API-key auth middleware on the real server router. Exclude the emitted spec and goldens from pre-commit checks. --- .pre-commit-config.yaml | 5 +- hscontrol/apiv1_apikeys_test.go | 212 +++++++++ hscontrol/apiv1_auth_test.go | 187 ++++++++ hscontrol/apiv1_authmw_test.go | 110 +++++ hscontrol/apiv1_harness_test.go | 276 ++++++++++++ hscontrol/apiv1_health_test.go | 24 + hscontrol/apiv1_nodes_test.go | 416 ++++++++++++++++++ hscontrol/apiv1_policy_test.go | 186 ++++++++ hscontrol/apiv1_preauthkeys_test.go | 163 +++++++ hscontrol/apiv1_users_test.go | 123 ++++++ ...piKeyDelete_both_id_and_prefix_parity.json | 4 + ...stAPIV1ApiKeyDelete_invalid_id_parity.json | 4 + ...iKeyDelete_not_found_by_prefix_parity.json | 4 + ...x_with_id_query_is_both_->_400_parity.json | 4 + ...piKeyExpire_both_id_and_prefix_parity.json | 4 + .../TestAPIV1ApiKeyExpire_by_id_parity.json | 4 + ...estAPIV1ApiKeyExpire_by_prefix_parity.json | 4 + ...eyExpire_neither_id_nor_prefix_parity.json | 4 + ...V1ApiKeyExpire_not_found_by_id_parity.json | 4 + .../TestAPIV1ApiKeyList_empty_parity.json | 6 + ...nil_timestamps_emitted_as_null_parity.json | 14 + .../TestAPIV1ApiKeyList_populated_parity.json | 21 + ...ration_emitted_as_zero_instant_parity.json | 14 + ...IV1AuthApprove_invalid_auth_id_parity.json | 4 + ...AuthApprove_no_pending_session_parity.json | 4 + ...stAPIV1AuthRegister_happy_path_parity.json | 34 ++ ...V1AuthRegister_invalid_auth_id_parity.json | 4 + ...uthRegister_no_pending_session_parity.json | 4 + ...APIV1AuthRegister_unknown_user_parity.json | 4 + ...PIV1AuthReject_invalid_auth_id_parity.json | 4 + ...1AuthReject_no_pending_session_parity.json | 4 + ...V1CreatePreAuthKey_invalid_tag_parity.json | 4 + ...eatePreAuthKey_invalid_user_id_parity.json | 4 + ...thKey_neither_tagged_nor_owned_parity.json | 4 + ...atePreAuthKey_nonexistent_user_parity.json | 4 + ...APIV1CreateUser_duplicate_name_parity.json | 4 + .../TestAPIV1CreateUser_parity.json | 15 + ...IV1DeletePreAuthKey_invalid_id_parity.json | 4 + ...V1DeletePreAuthKey_nonexistent_parity.json | 4 + .../TestAPIV1DeletePreAuthKey_parity.json | 4 + ...estAPIV1DeleteUser_nonexistent_parity.json | 4 + .../TestAPIV1DeleteUser_parity.json | 4 + ...IV1ExpirePreAuthKey_invalid_id_parity.json | 4 + ...V1ExpirePreAuthKey_nonexistent_parity.json | 4 + .../TestAPIV1ExpirePreAuthKey_parity.json | 4 + .../TestAPIV1Health_parity_with_gateway.json | 6 + .../TestAPIV1ListPreAuthKeys_all_parity.json | 40 ++ ...TestAPIV1ListPreAuthKeys_empty_parity.json | 6 + .../TestAPIV1ListUsers_all_parity.json | 37 ++ .../TestAPIV1ListUsers_empty_parity.json | 6 + ...estAPIV1ListUsers_filter_by_id_parity.json | 17 + ...tAPIV1ListUsers_filter_by_name_parity.json | 17 + .../TestAPIV1ListUsers_invalid_id_parity.json | 4 + ...odeBackfillIPs_confirmed_empty_parity.json | 6 + ...IV1NodeBackfillIPs_unconfirmed_parity.json | 4 + ...IV1NodeDebugCreate_invalid_key_parity.json | 4 + ...V1NodeDebugCreate_unknown_user_parity.json | 4 + .../TestAPIV1NodeDelete_happy_parity.json | 4 + ...TestAPIV1NodeDelete_invalid_id_parity.json | 4 + .../TestAPIV1NodeDelete_not_found_parity.json | 4 + ...TestAPIV1NodeExpire_invalid_id_parity.json | 4 + .../TestAPIV1NodeExpire_not_found_parity.json | 4 + .../TestAPIV1NodeGet_happy_parity.json | 53 +++ .../TestAPIV1NodeGet_invalid_id_parity.json | 4 + .../TestAPIV1NodeGet_not_found_parity.json | 4 + .../TestAPIV1NodeGet_tagged_node_parity.json | 48 ++ .../TestAPIV1NodeList_all_parity.json | 103 +++++ .../TestAPIV1NodeList_empty_parity.json | 6 + ...stAPIV1NodeList_filter_by_user_parity.json | 55 +++ ...TestAPIV1NodeList_unknown_user_parity.json | 4 + .../TestAPIV1NodeRegister_happy_parity.json | 34 ++ ...tAPIV1NodeRegister_invalid_key_parity.json | 4 + ...APIV1NodeRegister_unknown_user_parity.json | 4 + ...TestAPIV1NodeRename_invalid_id_parity.json | 4 + .../TestAPIV1NodeRename_not_found_parity.json | 4 + ...deSetApprovedRoutes_invalid_id_parity.json | 4 + ...odeSetApprovedRoutes_not_found_parity.json | 4 + ...estAPIV1NodeSetTags_empty_tags_parity.json | 4 + ...estAPIV1NodeSetTags_invalid_id_parity.json | 4 + ...TestAPIV1NodeSetTags_not_found_parity.json | 4 + ...V1NodeSetTags_unauthorized_tag_parity.json | 4 + .../TestAPIV1PolicyCheck_invalid_parity.json | 4 + .../TestAPIV1PolicyCheck_valid_parity.json | 4 + .../TestAPIV1PolicyGet_empty_parity.json | 4 + .../TestAPIV1PolicyGet_set_parity.json | 7 + .../TestAPIV1PolicySet_invalid_parity.json | 4 + .../TestAPIV1PolicySet_valid_parity.json | 7 + ...TestAPIV1RenameUser_invalid_id_parity.json | 4 + ...estAPIV1RenameUser_nonexistent_parity.json | 4 + .../TestAPIV1RenameUser_parity.json | 15 + 90 files changed, 2495 insertions(+), 2 deletions(-) create mode 100644 hscontrol/apiv1_apikeys_test.go create mode 100644 hscontrol/apiv1_auth_test.go create mode 100644 hscontrol/apiv1_authmw_test.go create mode 100644 hscontrol/apiv1_harness_test.go create mode 100644 hscontrol/apiv1_health_test.go create mode 100644 hscontrol/apiv1_nodes_test.go create mode 100644 hscontrol/apiv1_policy_test.go create mode 100644 hscontrol/apiv1_preauthkeys_test.go create mode 100644 hscontrol/apiv1_users_test.go create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1b06567..aa4355c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,8 +2,9 @@ # See: https://prek.j178.dev/quickstart/ # See: https://prek.j178.dev/builtin/ -# Global exclusions - ignore generated code -exclude: ^gen/ +# Global exclusions - ignore generated code (proto output and emitted OpenAPI) +# and recorded golden fixtures. +exclude: ^(gen|openapi)/|^hscontrol/testdata/apiv1_golden/ repos: # Built-in hooks from pre-commit/pre-commit-hooks diff --git a/hscontrol/apiv1_apikeys_test.go b/hscontrol/apiv1_apikeys_test.go new file mode 100644 index 00000000..74b4634a --- /dev/null +++ b/hscontrol/apiv1_apikeys_test.go @@ -0,0 +1,212 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedAPIKey creates a single API key and returns its database ID and the +// masked display prefix the API exposes, so tests can address it by either. +func seedAPIKey(t *testing.T, app *Headscale) (uint64, string) { + t.Helper() + + _, key, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + + return key.ID, "hskey-api-" + key.Prefix + "-***" +} + +func TestAPIV1ApiKeyCreate(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/apikey", []byte(`{}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + APIKey string `json:"apiKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + // The full secret is returned exactly once, on creation. + assert.NotEmpty(t, got.APIKey) + assert.Contains(t, got.APIKey, "hskey-api-") + }) + + t.Run("creates secret", func(t *testing.T) { + // The secret is random, so it can't be captured in a golden; just assert + // success and that a secret is returned. + humaApp := createTestApp(t) + + hum := callHandler(newHumaTestHandler(humaApp), http.MethodPost, "/api/v1/apikey", []byte(`{}`)) + + assert.Equal(t, http.StatusOK, hum.status) + + var humBody struct { + APIKey string `json:"apiKey"` + } + require.NoError(t, json.Unmarshal(hum.body, &humBody)) + assert.NotEmpty(t, humBody.APIKey) + }) +} + +func TestAPIV1ApiKeyList(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"apiKeys":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + }) + + t.Run("populated parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedAPIKey(t, h.app) + seedAPIKey(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + }) + + t.Run("nil timestamps emitted as null parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedAPIKey(t, h.app) + // Nil expiration: lastSeen and expiration are unset and must emit as + // JSON null. + res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []map[string]any `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + require.Len(t, got.APIKeys, 1) + assert.Nil(t, got.APIKeys[0]["lastSeen"]) + assert.Nil(t, got.APIKeys[0]["expiration"]) + assert.Contains(t, got.APIKeys[0], "createdAt") + assert.Contains(t, got.APIKeys[0]["prefix"], "hskey-api-") + }) + + t.Run("zero expiration emitted as zero instant parity", func(t *testing.T) { + h := newAPIV1Harness(t) + // Non-nil zero expiration (as gRPC CreateApiKey does for a missing one) + // must render as the zero instant, not null. + var zero time.Time + + _, _, err := h.app.state.CreateAPIKey(&zero) + require.NoError(t, err) + + res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []map[string]any `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + require.Len(t, got.APIKeys, 1) + assert.Equal(t, "0001-01-01T00:00:00Z", got.APIKeys[0]["expiration"]) + }) +} + +func TestAPIV1ApiKeyExpire(t *testing.T) { + t.Run("by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + id, _ := seedAPIKey(t, h.app) + h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`)) + }) + + t.Run("by prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + _, prefix := seedAPIKey(t, h.app) + h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"prefix":"`+prefix+`"}`)) + }) + + t.Run("neither id nor prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("both id and prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"1","prefix":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("not found by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{"id":"999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("expires the key", func(t *testing.T) { + h := newAPIV1Harness(t) + id, _ := seedAPIKey(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`)) + require.Equal(t, http.StatusOK, res.status) + + listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []struct { + Expiration *time.Time `json:"expiration"` + } `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(listRes.body, &got)) + require.Len(t, got.APIKeys, 1) + require.NotNil(t, got.APIKeys[0].Expiration) + assert.False(t, got.APIKeys[0].Expiration.IsZero(), "expiration should be set") + }) +} + +func TestAPIV1ApiKeyDelete(t *testing.T) { + t.Run("by prefix deletes the key", func(t *testing.T) { + h := newAPIV1Harness(t) + _, prefix := seedAPIKey(t, h.app) + + res := h.callHuma(http.MethodDelete, "/api/v1/apikey/"+prefix, nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + assert.JSONEq(t, `{"apiKeys":[]}`, string(listRes.body)) + }) + + t.Run("path prefix with id query is both -> 400 parity", func(t *testing.T) { + h := newAPIV1Harness(t) + id, prefix := seedAPIKey(t, h.app) + res := h.assertParity(t, http.MethodDelete, + "/api/v1/apikey/"+prefix+"?id="+strconv.FormatUint(id, 10), nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("not found by prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/doesnotexist", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("both id and prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=1", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=notanumber", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} diff --git a/hscontrol/apiv1_auth_test.go b/hscontrol/apiv1_auth_test.go new file mode 100644 index 00000000..1f157f41 --- /dev/null +++ b/hscontrol/apiv1_auth_test.go @@ -0,0 +1,187 @@ +package hscontrol + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// seedAuthRequest stores a pending registration auth cache entry under authID so +// an AuthRegister/Approve/Reject call has a real session to act on. Capturing +// the keys/authID at the call site keeps isolated apps identical. +func seedAuthRequest( + user string, + authID types.AuthID, + machineKey key.MachinePublic, + nodeKey key.NodePublic, + discoKey key.DiscoPublic, + hostname string, +) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + app.state.CreateUserForTest(user) + + regData := &types.RegistrationData{ + MachineKey: machineKey, + NodeKey: nodeKey, + DiscoKey: discoKey, + Hostname: hostname, + Hostinfo: &tailcfg.Hostinfo{Hostname: hostname}, + } + + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + } +} + +func TestAPIV1AuthRegister(t *testing.T) { + t.Run("happy path parity", func(t *testing.T) { + // Capture keys/authID once so both isolated apps register the identical + // node and produce byte-equal bodies. + authID := types.MustAuthID() + machineKey := key.NewMachine().Public() + nodeKey := key.NewNode().Public() + discoKey := key.NewDisco().Public() + + seed := seedAuthRequest("alice", authID, machineKey, nodeKey, discoKey, "regnode") + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String()) + + assertParityIsolated(t, seed, http.MethodPost, "/api/v1/auth/register", body) + }) + + t.Run("happy path response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + seedAuthRequest( + "alice", authID, + key.NewMachine().Public(), + key.NewNode().Public(), + key.NewDisco().Public(), + "regnode", + )(t, h.app) + + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/register", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + assert.Equal(t, "REGISTER_METHOD_CLI", got.Node["registerMethod"]) + // EmitUnpopulated parity: an unset expiry (we passed none) is null, an + // unset embedded pre-auth key is null, and repeated fields are [] + // rather than omitted. + assert.Contains(t, got.Node, "expiry") + assert.Nil(t, got.Node["expiry"]) + assert.Contains(t, got.Node, "preAuthKey") + assert.Nil(t, got.Node["preAuthKey"]) + assert.Equal(t, []any{}, got.Node["subnetRoutes"]) + assert.NotNil(t, got.Node["user"]) + }) + + // A malformed auth_id is bad input (400), matching AuthApprove/AuthReject. + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", + []byte(`{"user":"alice","authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"user":"ghost","authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body) + assertStatus(t, res, http.StatusNotFound) + }) + + // Valid auth_id but no cached session: HandleNodeFromAuthPath returns + // ErrNodeNotFoundRegistrationCache, which maps to 404. + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1AuthApprove(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + authReq := types.NewAuthRequest() + h.app.state.SetAuthCacheEntry(authID, authReq) + + body := fmt.Appendf(nil, `{"authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/approve", body) + + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + verdict := <-authReq.WaitForAuth() + assert.True(t, verdict.Accept(), "approve must finish the session with a passing verdict") + }) + + // Malformed auth_id: AuthApprove returns codes.InvalidArgument → 400. + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", + []byte(`{"authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + // Well-formed auth_id with no pending session: codes.NotFound → 404. + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", body) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1AuthReject(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + authReq := types.NewAuthRequest() + h.app.state.SetAuthCacheEntry(authID, authReq) + + body := fmt.Appendf(nil, `{"authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/reject", body) + + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + verdict := <-authReq.WaitForAuth() + assert.False(t, verdict.Accept(), "reject must finish the session with a failing verdict") + }) + + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", + []byte(`{"authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", body) + assertStatus(t, res, http.StatusNotFound) + }) +} diff --git a/hscontrol/apiv1_authmw_test.go b/hscontrol/apiv1_authmw_test.go new file mode 100644 index 00000000..814f839d --- /dev/null +++ b/hscontrol/apiv1_authmw_test.go @@ -0,0 +1,110 @@ +package hscontrol + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1AuthMiddleware proves the v1 API, mounted on the real router, is +// guarded by the Huma security middleware: missing or bad Bearer tokens are +// rejected with 401 before reaching a handler; a valid key passes to 200. The +// per-endpoint harness bypasses auth via WithLocalTrust, so this is the one +// place the middleware itself is exercised. +func TestAPIV1AuthMiddleware(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + expiry := time.Now().Add(time.Hour) + valid, _, err := app.state.CreateAPIKey(&expiry) + require.NoError(t, err) + + tests := []struct { + name string + authHeader string + wantStatus int + }{ + {name: "missing bearer", authHeader: "", wantStatus: http.StatusUnauthorized}, + {name: "no bearer prefix", authHeader: "tskey-invalid", wantStatus: http.StatusUnauthorized}, + {name: "invalid bearer token", authHeader: "Bearer tskey-invalid", wantStatus: http.StatusUnauthorized}, + {name: "valid api key", authHeader: "Bearer " + valid, wantStatus: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/api/v1/node", nil, + ) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equalf(t, tt.wantStatus, rec.Code, "body: %s", rec.Body.String()) + }) + } +} + +// TestAPIV1DocsArePublic proves the OpenAPI document and docs UI live under +// /api/v1 and are reachable without an API key, while the operations beside them +// stay key-gated. The docs page points at the versioned spec so a future +// /api/v2 can carry its own. +func TestAPIV1DocsArePublic(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + tests := []struct { + path string + contains string + }{ + {path: "/api/v1/openapi.yaml", contains: "openapi:"}, + {path: "/api/v1/docs", contains: "/api/v1/openapi.yaml"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + // No Authorization header: these must be public. + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, tt.path, nil, + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equalf(t, http.StatusOK, rec.Code, "%s should be public; body: %s", tt.path, rec.Body.String()) + assert.Containsf(t, rec.Body.String(), tt.contains, "%s body", tt.path) + }) + } +} + +// TestAPIV1Unauthorized401 pins the 401 body: RFC 7807 JSON containing +// "Unauthorized", under 100 bytes and leaking no data, as the integration +// auth-bypass tests require. +func TestAPIV1Unauthorized401(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/api/v1/node", nil, + ) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + + body := rec.Body.Bytes() + assert.Contains(t, string(body), "Unauthorized") + assert.Lessf(t, len(body), 100, "401 body must stay small (no data leak): %s", body) + + var problem map[string]any + require.NoError(t, json.Unmarshal(body, &problem), "401 body must be JSON: %s", body) + assert.NotContains(t, problem, "users") +} diff --git a/hscontrol/apiv1_harness_test.go b/hscontrol/apiv1_harness_test.go new file mode 100644 index 00000000..256ccf89 --- /dev/null +++ b/hscontrol/apiv1_harness_test.go @@ -0,0 +1,276 @@ +package hscontrol + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// apiV1Harness drives a request through the Huma service and compares the result +// against a golden keyed by the test name. Goldens neutralise timestamps so they +// stay stable across runs; refresh with HEADSCALE_UPDATE_GOLDEN=1 after an +// intentional contract change. assertParity suits reads/errors; mutations use +// assertParityIsolated against a freshly-seeded app. +type apiV1Harness struct { + app *Headscale + huma http.Handler +} + +func newAPIV1Harness(t *testing.T) *apiV1Harness { + t.Helper() + + app := createTestApp(t) + + return &apiV1Harness{ + app: app, + huma: newHumaTestHandler(app), + } +} + +// newHumaTestHandler wraps the Huma handler in WithLocalTrust to bypass the +// bearer-key middleware — the same local-trust the unix socket gets — so these +// tests can exercise response shapes. Auth itself is covered against the full +// router in TestAPIV1AuthMiddleware. +func newHumaTestHandler(app *Headscale) http.Handler { + mux, _ := apiv1.Handler(apiv1.Backend{ + State: app.state, + Change: app.Change, + Cfg: app.cfg, + }) + + return apiv1.WithLocalTrust(mux) +} + +// httpResult is one HTTP exchange captured for comparison. +type httpResult struct { + status int + body []byte +} + +func callHandler(handler http.Handler, method, path string, body []byte) httpResult { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req := httptest.NewRequestWithContext(context.Background(), method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + return httpResult{status: rec.Code, body: rec.Body.Bytes()} +} + +func (h *apiV1Harness) callHuma(method, path string, body []byte) httpResult { + return callHandler(h.huma, method, path, body) +} + +// assertParity asserts the Huma response matches the golden: equal status, and +// for success an equal JSON body (timestamps neutralised). Error bodies are not +// compared — the shape deliberately deviates from legacy +// ({code,message,details} vs Huma RFC7807). +func (h *apiV1Harness) assertParity(t *testing.T, method, path string, body []byte) httpResult { + t.Helper() + + hum := h.callHuma(method, path, body) + + assertAgainstGolden(t, method, path, hum) + + return hum +} + +// assertParityIsolated runs the request against a fresh app seeded by seed (may +// be nil) and compares to the golden. Use for mutations. +func assertParityIsolated( + t *testing.T, + seed func(t *testing.T, app *Headscale), + method, path string, + body []byte, +) httpResult { + t.Helper() + + humaApp := createTestApp(t) + if seed != nil { + seed(t, humaApp) + } + + hum := callHandler(newHumaTestHandler(humaApp), method, path, body) + + assertAgainstGolden(t, method, path, hum) + + return hum +} + +// assertStatus pins the exact HTTP status independently of the golden, so a +// regression fails even if the golden is blindly regenerated. +func assertStatus(t *testing.T, got httpResult, want int) { + t.Helper() + + assert.Equalf(t, want, got.status, + "unexpected HTTP status (golden-independent guard); body: %s", got.body) +} + +func isSuccess(status int) bool { + return status >= 200 && status < 300 +} + +const goldenDir = "testdata/apiv1_golden" + +// goldenRecord is the persisted shape of a captured response: the HTTP status +// and, when present, the normalised JSON body. +type goldenRecord struct { + Status int `json:"status"` + Body json.RawMessage `json:"body"` +} + +func goldenPath(t *testing.T) string { + t.Helper() + + name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + + return filepath.Join(goldenDir, name+".json") +} + +// assertAgainstGolden compares the Huma response to the recorded golden: status +// always, body only for success responses. +func assertAgainstGolden(t *testing.T, method, path string, hum httpResult) { + t.Helper() + + gpath := goldenPath(t) + + if os.Getenv("HEADSCALE_UPDATE_GOLDEN") != "" { + writeGolden(t, gpath, hum) + + return + } + + raw, err := os.ReadFile(gpath) + require.NoErrorf(t, err, + "missing golden %s for %s %s — run with HEADSCALE_UPDATE_GOLDEN=1 to generate", + gpath, method, path) + + var golden goldenRecord + require.NoErrorf(t, json.Unmarshal(raw, &golden), "decoding golden %s", gpath) + + assert.Equalf(t, golden.Status, hum.status, + "status mismatch for %s %s\nhuma body: %s", method, path, hum.body) + + if isSuccess(golden.Status) { + var goldenBody any + if len(golden.Body) > 0 { + goldenBody = normalizeJSON(t, golden.Body, true) + } + + assert.Equalf(t, goldenBody, normalizeJSON(t, hum.body, true), + "body mismatch for %s %s", method, path) + } +} + +// writeGolden persists the response under HEADSCALE_UPDATE_GOLDEN. Success +// bodies are normalised the same way the comparison path does, so the file stays +// stable across runs. Error responses are stored status-only, since the reader +// never compares error bodies. +func writeGolden(t *testing.T, gpath string, hum httpResult) { + t.Helper() + + rec := goldenRecord{Status: hum.status} + + if isSuccess(hum.status) { + if normalised := normalizeJSON(t, hum.body, true); normalised != nil { + raw, err := json.Marshal(normalised) + require.NoErrorf(t, err, "marshalling golden body for %s", gpath) + + rec.Body = raw + } + } + + out, err := json.MarshalIndent(rec, "", " ") + require.NoErrorf(t, err, "marshalling golden record for %s", gpath) + + require.NoErrorf(t, os.MkdirAll(filepath.Dir(gpath), 0o755), + "creating golden dir for %s", gpath) + require.NoErrorf(t, os.WriteFile(gpath, append(out, '\n'), 0o600), + "writing golden %s", gpath) + + t.Logf("updated golden %s", gpath) +} + +// nonDeterministicRe matches values that embed per-app random material and so +// cannot match byte-for-byte across apps: masked key/secret prefixes +// (hskey-auth--***, hskey-api--***) and Tailscale key material +// (mkey:/nodekey:/discokey:). Neutralised to a sentinel when neutralise is +// true. +var nonDeterministicRe = regexp.MustCompile( + `^(hskey-(auth|api)-[0-9a-f]+-\*\*\*|(mkey|nodekey|discokey):[0-9a-f]+)$`, +) + +// normalizeJSON decodes JSON for order-independent, type-aware comparison. +// Numbers decode as json.Number so "5" and 5 are not conflated, surfacing +// string-encoding differences. With neutralise, timestamps and per-app random +// values (see nonDeterministicRe) become sentinels; otherwise timestamps are +// canonicalised to a UTC instant. +func normalizeJSON(t *testing.T, b []byte, neutralise bool) any { + t.Helper() + + if len(bytes.TrimSpace(b)) == 0 { + return nil + } + + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + + var v any + require.NoErrorf(t, dec.Decode(&v), "decoding JSON: %s", b) + + return canonicalizeTimestamps(v, neutralise) +} + +func canonicalizeTimestamps(v any, neutralise bool) any { + switch val := v.(type) { + case map[string]any: + for k, child := range val { + val[k] = canonicalizeTimestamps(child, neutralise) + } + + return val + case []any: + for i, child := range val { + val[i] = canonicalizeTimestamps(child, neutralise) + } + + return val + case string: + if neutralise && nonDeterministicRe.MatchString(val) { + return "" + } + + ts, err := time.Parse(time.RFC3339Nano, val) + if err != nil { + return val + } + + if neutralise { + return "" + } + + return ts.UTC().Format(time.RFC3339Nano) + default: + return v + } +} diff --git a/hscontrol/apiv1_health_test.go b/hscontrol/apiv1_health_test.go new file mode 100644 index 00000000..6e25ddb2 --- /dev/null +++ b/hscontrol/apiv1_health_test.go @@ -0,0 +1,24 @@ +package hscontrol + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIV1Health(t *testing.T) { + h := newAPIV1Harness(t) + + t.Run("huma returns healthy with database connectivity", func(t *testing.T) { + res := h.callHuma(http.MethodGet, "/api/v1/health", nil) + + assert.Equal(t, http.StatusOK, res.status) + require.JSONEq(t, `{"databaseConnectivity":true}`, string(res.body)) + }) + + t.Run("parity with gateway", func(t *testing.T) { + h.assertParity(t, http.MethodGet, "/api/v1/health", nil) + }) +} diff --git a/hscontrol/apiv1_nodes_test.go b/hscontrol/apiv1_nodes_test.go new file mode 100644 index 00000000..dc6e2222 --- /dev/null +++ b/hscontrol/apiv1_nodes_test.go @@ -0,0 +1,416 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// nodeSeed carries fixed key material so isolated apps in a mutation parity test +// register byte-identical nodes; otherwise random keys would defeat body +// comparison. +type nodeSeed struct { + user string + machineKey key.MachinePrivate + nodeKey key.NodePrivate + hostname string + tags []string +} + +func newNodeSeed(user, hostname string, tags ...string) nodeSeed { + return nodeSeed{ + user: user, + machineKey: key.NewMachine(), + nodeKey: key.NewNode(), + hostname: hostname, + tags: tags, + } +} + +// register inserts the user, a matching pre-auth key, and the node itself using +// the seed's fixed keys, returning the created node ID. +func (s nodeSeed) register(t *testing.T, app *Headscale) types.NodeID { + t.Helper() + + user := app.state.CreateUserForTest(s.user) + + pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, s.tags) + require.NoError(t, err) + + req := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: s.nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: s.hostname}, + } + + _, err = app.handleRegisterWithAuthKey(req, s.machineKey.Public()) + require.NoError(t, err) + + node, found := app.state.GetNodeByNodeKey(s.nodeKey.Public()) + require.True(t, found) + + return node.ID() +} + +func seedNodes(seeds ...nodeSeed) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + for _, s := range seeds { + s.register(t, app) + } + } +} + +func TestAPIV1NodeGet(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil) + }) + + t.Run("tagged node parity", func(t *testing.T) { + h := newAPIV1Harness(t) + // No tagOwners means policy won't authorise a tag, so register via the + // pre-auth key path, which forces tags regardless of policy. + seedNodes(newNodeSeed("bob", "node-b", "tag:initial"))(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/node/99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/node/abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("carol", "node-c"))(t, h.app) + + res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + assert.Equal(t, "node-c", got.Node["name"]) + assert.Equal(t, "REGISTER_METHOD_AUTH_KEY", got.Node["registerMethod"]) + // EmitUnpopulated parity: slices present as [], expiry as null. + assert.Equal(t, []any{}, got.Node["ipAddresses"]) + assert.Equal(t, []any{}, got.Node["tags"]) + assert.Nil(t, got.Node["expiry"]) + assert.Contains(t, got.Node, "user") + assert.Contains(t, got.Node, "preAuthKey") + assert.Contains(t, got.Node, "createdAt") + }) +} + +func TestAPIV1NodeList(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/node", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"nodes":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/node", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes( + newNodeSeed("alice", "node-a"), + newNodeSeed("bob", "node-b"), + )(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node", nil) + }) + + t.Run("filter by user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes( + newNodeSeed("alice", "node-a"), + newNodeSeed("bob", "node-b"), + )(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node?user=alice", nil) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodGet, "/api/v1/node?user=nope", nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1NodeDelete(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + assertParityIsolated(t, seedNodes(newNodeSeed("alice", "node-a")), + http.MethodDelete, "/api/v1/node/1", nil) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/node/99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/node/abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeExpire(t *testing.T) { + // The embedded pre-auth key's masked prefix is random per app, so isolated + // body comparison is impossible. GetNode/ListNodes prove full serialisation; + // here we just assert the mutation took effect. + t.Run("huma expires node", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + // Expiry was nil before; expiring sets it to a concrete timestamp. + assert.NotNil(t, got.Node["expiry"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/expire", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/expire", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeRename(t *testing.T) { + t.Run("huma renames node", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/rename/renamed-node", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "renamed-node", got.Node["givenName"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/rename/whatever", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/rename/whatever", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeSetTags(t *testing.T) { + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/tags", + []byte(`{"tags":["tag:foo"]}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("empty tags parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", []byte(`{"tags":[]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unauthorized tag parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + // No tagOwners in policy → SetNodeTags rejects with InvalidArgument (400). + res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", + []byte(`{"tags":["tag:server"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/tags", + []byte(`{"tags":["tag:foo"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeSetApprovedRoutes(t *testing.T) { + t.Run("huma sets approved routes", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/approve_routes", + []byte(`{"routes":[]}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + // SubnetRoutes is recomputed from primary routes; empty here but present. + assert.Equal(t, []any{}, got.Node["approvedRoutes"]) + assert.Equal(t, []any{}, got.Node["subnetRoutes"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/approve_routes", + []byte(`{"routes":["10.0.0.0/24"]}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/approve_routes", + []byte(`{"routes":[]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeRegister(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + authID := types.MustAuthID() + mk := key.NewMachine() + nk := key.NewNode() + + seed := func(t *testing.T, app *Headscale) { + t.Helper() + + app.state.CreateUserForTest("alice") + + regData := &types.RegistrationData{ + NodeKey: nk.Public(), + MachineKey: mk.Public(), + Hostname: "registered-node", + } + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + } + + assertParityIsolated(t, seed, http.MethodPost, + "/api/v1/node/register?user=alice&key="+authID.String(), nil) + }) + + t.Run("invalid key parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodPost, + "/api/v1/node/register?user=alice&key=invalidkey", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, + "/api/v1/node/register?user=nope&key="+types.MustAuthID().String(), nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1NodeBackfillIPs(t *testing.T) { + t.Run("confirmed empty parity", func(t *testing.T) { + assertParityIsolated(t, nil, http.MethodPost, + "/api/v1/node/backfillips?confirmed=true", nil) + }) + + t.Run("confirmed returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/node/backfillips?confirmed=true", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"changes":[]}`, string(res.body)) + }) + + t.Run("unconfirmed parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/backfillips", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeDebugCreate(t *testing.T) { + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := []byte(`{"user":"nope","key":"` + types.MustAuthID().String() + + `","name":"dbg","routes":[]}`) + res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid key parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + body := []byte(`{"user":"alice","key":"badkey","name":"dbg","routes":[]}`) + res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + // The handler mints fresh key material per call, so isolated apps can't + // produce byte-identical bodies; assert the shape directly instead. + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + + body := []byte(`{"user":"alice","key":"` + types.MustAuthID().String() + + `","name":"dbgnode","routes":["10.0.0.0/24"]}`) + + res := h.callHuma(http.MethodPost, "/api/v1/debug/node", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "dbgnode", got.Node["name"]) + assert.Equal(t, "REGISTER_METHOD_UNSPECIFIED", got.Node["registerMethod"]) + assert.Equal(t, []any{"10.0.0.0/24"}, got.Node["availableRoutes"]) + // The synthetic echo node has no pre-auth key: emitted as null. + assert.Nil(t, got.Node["preAuthKey"]) + // Zero-time expiry/lastSeen are emitted as the zero instant, not null. + assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["expiry"]) + assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["lastSeen"]) + }) +} diff --git a/hscontrol/apiv1_policy_test.go b/hscontrol/apiv1_policy_test.go new file mode 100644 index 00000000..0c4cfefa --- /dev/null +++ b/hscontrol/apiv1_policy_test.go @@ -0,0 +1,186 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/mapper" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validPolicy is a minimal policy that passes validation without nodes or users. +const validPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// invalidPolicy is not valid HuJSON, so policy manager construction fails. +const invalidPolicy = `{"acls": [` + +// seedPolicy stores a policy in the database so reads return it. +func seedPolicy(policy string) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + _, err := app.state.SetPolicyInDB(policy) + require.NoError(t, err) + } +} + +func TestAPIV1PolicyGet(t *testing.T) { + t.Run("empty parity", func(t *testing.T) { + // With no policy stored, the DB load fails; this is treated as a server + // fault (500), matching the legacy contract. + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/policy", nil) + assertStatus(t, res, http.StatusInternalServerError) + }) + + t.Run("set parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPolicy(validPolicy)(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/policy", nil) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPolicy(validPolicy)(t, h.app) + + res := h.callHuma(http.MethodGet, "/api/v1/policy", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Policy string `json:"policy"` + UpdatedAt string `json:"updatedAt"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.JSONEq(t, validPolicy, got.Policy) + // updatedAt is emitted even though no omitempty is set. + assert.NotEmpty(t, got.UpdatedAt) + }) +} + +func TestAPIV1PolicySet(t *testing.T) { + t.Run("valid parity", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body) + }) + + t.Run("invalid parity", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"policy": invalidPolicy}) + require.NoError(t, err) + + res := assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + res := h.callHuma(http.MethodPut, "/api/v1/policy", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Policy string `json:"policy"` + UpdatedAt string `json:"updatedAt"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.JSONEq(t, validPolicy, got.Policy) + assert.NotEmpty(t, got.UpdatedAt) + }) + + t.Run("not-db mode rejected", func(t *testing.T) { + // Policy updates are only valid in DB mode; file mode rejects with 400. + // createTestApp is DB mode, so build a file-mode app here. + humaApp := createFilePolicyApp(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + hum := callHandler(newHumaTestHandler(humaApp), http.MethodPut, "/api/v1/policy", body) + + assert.Equal(t, http.StatusBadRequest, hum.status, + "huma body: %s", hum.body) + }) +} + +func TestAPIV1PolicyCheck(t *testing.T) { + t.Run("valid parity", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body) + }) + + t.Run("invalid parity", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": invalidPolicy}) + require.NoError(t, err) + + res := h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("valid returns empty object", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + res := h.callHuma(http.MethodPost, "/api/v1/policy/check", body) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + }) +} + +// createFilePolicyApp mirrors createTestApp but with file-based policy mode so +// the policy-update-disabled path can be exercised. +func createFilePolicyApp(t *testing.T) *Headscale { + t.Helper() + + tmpDir := t.TempDir() + + cfg := types.Config{ + ServerURL: "http://localhost:8080", + NoisePrivateKeyPath: tmpDir + "/noise_private.key", + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: tmpDir + "/headscale_test.db", + }, + }, + OIDC: types.OIDCConfig{}, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeFile, + }, + Tuning: types.Tuning{ + BatchChangeDelay: 100 * time.Millisecond, + BatcherWorkers: 1, + }, + } + + app, err := NewHeadscale(&cfg) + require.NoError(t, err) + + app.mapBatcher = mapper.NewBatcherAndMapper(&cfg, app.state) + app.mapBatcher.Start() + + t.Cleanup(func() { + if app.mapBatcher != nil { + app.mapBatcher.Close() + } + }) + + return app +} diff --git a/hscontrol/apiv1_preauthkeys_test.go b/hscontrol/apiv1_preauthkeys_test.go new file mode 100644 index 00000000..2df5f8e6 --- /dev/null +++ b/hscontrol/apiv1_preauthkeys_test.go @@ -0,0 +1,163 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedPreAuthKeys creates a user-owned key and a tagged (system-created) key; +// the tagged key exercises the user:null path. +func seedPreAuthKeys() func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + user := app.state.CreateUserForTest("alice") + uid := types.UserID(user.ID) + exp := time.Time{} + + _, err := app.state.CreatePreAuthKey(&uid, true, false, &exp, nil) + require.NoError(t, err) + + // Tagged, system-created: no user. + _, err = app.state.CreatePreAuthKey(nil, false, true, &exp, []string{"tag:test"}) + require.NoError(t, err) + } +} + +func TestAPIV1CreatePreAuthKey(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + + res := h.callHuma(http.MethodPost, "/api/v1/preauthkey", + []byte(`{"user":"1","reusable":true}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + PreAuthKey map[string]any `json:"preAuthKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.PreAuthKey["id"]) + assert.Equal(t, true, got.PreAuthKey["reusable"]) + // Zero-value fields are emitted (EmitUnpopulated parity). + assert.Equal(t, false, got.PreAuthKey["ephemeral"]) + assert.Equal(t, false, got.PreAuthKey["used"]) + assert.Contains(t, got.PreAuthKey, "expiration") + assert.Contains(t, got.PreAuthKey, "createdAt") + // Empty tags serialize as [], not null. + assert.Equal(t, []any{}, got.PreAuthKey["aclTags"]) + // The freshly created key returns its full secret. + assert.NotEmpty(t, got.PreAuthKey["key"]) + user, ok := got.PreAuthKey["user"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "1", user["id"]) + }) + + t.Run("tagged key has null user", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/preauthkey", + []byte(`{"aclTags":["tag:test"]}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + PreAuthKey map[string]any `json:"preAuthKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Nil(t, got.PreAuthKey["user"]) + assert.Equal(t, []any{"tag:test"}, got.PreAuthKey["aclTags"]) + }) + + t.Run("invalid tag parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", + []byte(`{"user":"1","aclTags":["badtag"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("nonexistent user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid user id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("neither tagged nor owned parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"reusable":true}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1ExpirePreAuthKey(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost, + "/api/v1/preauthkey/expire", []byte(`{"id":"1"}`)) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost, + "/api/v1/preauthkey/expire", []byte(`{"id":"99999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey/expire", []byte(`{"id":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1DeletePreAuthKey(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete, + "/api/v1/preauthkey?id=1", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete, + "/api/v1/preauthkey?id=99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/preauthkey?id=abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1ListPreAuthKeys(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/preauthkey", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"preAuthKeys":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPreAuthKeys()(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil) + }) +} diff --git a/hscontrol/apiv1_users_test.go b/hscontrol/apiv1_users_test.go new file mode 100644 index 00000000..428ba21c --- /dev/null +++ b/hscontrol/apiv1_users_test.go @@ -0,0 +1,123 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedUsers(names ...string) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + for _, n := range names { + app.state.CreateUserForTest(n) + } + } +} + +func TestAPIV1CreateUser(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/user", []byte(`{"name":"test"}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + User map[string]any `json:"user"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.User["id"]) + assert.Equal(t, "test", got.User["name"]) + assert.Contains(t, got.User, "createdAt") + // Zero-value fields are emitted as empty strings (EmitUnpopulated parity). + assert.Empty(t, got.User["email"]) + assert.Empty(t, got.User["displayName"]) + }) + + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, nil, http.MethodPost, "/api/v1/user", + []byte(`{"name":"test","displayName":"Test","email":"t@example.com"}`)) + }) + + t.Run("duplicate name parity", func(t *testing.T) { + res := assertParityIsolated(t, seedUsers("dup"), http.MethodPost, "/api/v1/user", + []byte(`{"name":"dup"}`)) + assertStatus(t, res, http.StatusConflict) + }) +} + +func TestAPIV1RenameUser(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedUsers("alice"), http.MethodPost, + "/api/v1/user/1/rename/bob", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/user/999/rename/bob", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/user/abc/rename/bob", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1DeleteUser(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedUsers("alice"), http.MethodDelete, "/api/v1/user/1", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/user/999", nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1ListUsers(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/user", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"users":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/user", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob", "carol")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user", nil) + }) + + t.Run("filter by name parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user?name=alice", nil) + }) + + t.Run("filter by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user?id=2", nil) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + res := h.assertParity(t, http.MethodGet, "/api/v1/user?id=abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json new file mode 100644 index 00000000..8a881a46 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "apiKeys": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json new file mode 100644 index 00000000..e96d0111 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "body": { + "apiKeys": [ + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": null, + "id": "1", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json new file mode 100644 index 00000000..1a838cf4 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json @@ -0,0 +1,21 @@ +{ + "status": 200, + "body": { + "apiKeys": [ + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": null, + "id": "1", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + }, + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": null, + "id": "2", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json new file mode 100644 index 00000000..e5fd9e01 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "body": { + "apiKeys": [ + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": "\u003ctimestamp\u003e", + "id": "1", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json new file mode 100644 index 00000000..628457d4 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json @@ -0,0 +1,34 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "regnode", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "regnode", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": null, + "registerMethod": "REGISTER_METHOD_CLI", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json new file mode 100644 index 00000000..0704ac4c --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json @@ -0,0 +1,4 @@ +{ + "status": 409, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json new file mode 100644 index 00000000..4349ea0e --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json @@ -0,0 +1,15 @@ +{ + "status": 200, + "body": { + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "Test", + "email": "t@example.com", + "id": "1", + "name": "test", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json b/hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json new file mode 100644 index 00000000..668b62ef --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "databaseConnectivity": true + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json new file mode 100644 index 00000000..cd8a9305 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json @@ -0,0 +1,40 @@ +{ + "status": 200, + "body": { + "preAuthKeys": [ + { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": "\u003ctimestamp\u003e", + "id": "1", + "key": "\u003csecret\u003e", + "reusable": true, + "used": false, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + { + "aclTags": [ + "tag:test" + ], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": true, + "expiration": "\u003ctimestamp\u003e", + "id": "2", + "key": "\u003csecret\u003e", + "reusable": false, + "used": false, + "user": null + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json new file mode 100644 index 00000000..1a1dafbd --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "preAuthKeys": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json new file mode 100644 index 00000000..6d789758 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json @@ -0,0 +1,37 @@ +{ + "status": 200, + "body": { + "users": [ + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + }, + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + }, + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "3", + "name": "carol", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json new file mode 100644 index 00000000..5fdc5311 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "users": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json new file mode 100644 index 00000000..b3d1bf4a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json @@ -0,0 +1,17 @@ +{ + "status": 200, + "body": { + "users": [ + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json new file mode 100644 index 00000000..48b345b0 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json @@ -0,0 +1,17 @@ +{ + "status": 200, + "body": { + "users": [ + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json new file mode 100644 index 00000000..3b98955b --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "changes": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json new file mode 100644 index 00000000..82318d03 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json @@ -0,0 +1,53 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-a", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-a", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json new file mode 100644 index 00000000..a55a4e79 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json @@ -0,0 +1,48 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-b", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-b", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [ + "tag:initial" + ], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [ + "tag:initial" + ], + "user": null + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json new file mode 100644 index 00000000..2dae3fb7 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json @@ -0,0 +1,103 @@ +{ + "status": 200, + "body": { + "nodes": [ + { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-a", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-a", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-b", + "id": "2", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-b", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "2", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json new file mode 100644 index 00000000..1e2f04d2 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "nodes": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json new file mode 100644 index 00000000..867d783c --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json @@ -0,0 +1,55 @@ +{ + "status": 200, + "body": { + "nodes": [ + { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-a", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-a", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json new file mode 100644 index 00000000..647d42c1 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json @@ -0,0 +1,34 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "registered-node", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "registered-node", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": null, + "registerMethod": "REGISTER_METHOD_CLI", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json new file mode 100644 index 00000000..01e7b2de --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json new file mode 100644 index 00000000..2e20a6ad --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json @@ -0,0 +1,4 @@ +{ + "status": 500, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json new file mode 100644 index 00000000..dc080d6a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json @@ -0,0 +1,7 @@ +{ + "status": 200, + "body": { + "policy": "{\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}", + "updatedAt": "\u003ctimestamp\u003e" + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json new file mode 100644 index 00000000..dc080d6a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json @@ -0,0 +1,7 @@ +{ + "status": 200, + "body": { + "policy": "{\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}", + "updatedAt": "\u003ctimestamp\u003e" + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json new file mode 100644 index 00000000..876045e3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json new file mode 100644 index 00000000..ee9c4b88 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json new file mode 100644 index 00000000..386034b3 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json @@ -0,0 +1,15 @@ +{ + "status": 200, + "body": { + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } +}