diff --git a/README.md b/README.md index 834786a5d..0a2aa2c5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hermes -![](https://github.com/hashicorp-forge/hermes/workflows/ci/badge.svg) +[![CI](https://github.com/hashicorp-forge/hermes/workflows/ci/badge.svg?branch=main)](https://github.com/hashicorp-forge/hermes/actions/workflows/ci.yml?query=branch%3Amain) > Hermes is not an official HashiCorp project. > The repository contains software which is under active development and is in the alpha stage. Please read the “[Project Status](#project-status)” section for more information. diff --git a/configs/config.hcl b/configs/config.hcl index bd359d6bf..73f7aa381 100644 --- a/configs/config.hcl +++ b/configs/config.hcl @@ -2,6 +2,10 @@ // URL of the application. base_url = "http://localhost:8000" +// log_format configures the logging format. Supported values are "standard" or +// "json". +log_format = "standard" + // algolia configures Hermes to work with Algolia. algolia { application_id = "" diff --git a/internal/cmd/commands/indexer/indexer.go b/internal/cmd/commands/indexer/indexer.go index 6a095a4d9..a93c131f4 100644 --- a/internal/cmd/commands/indexer/indexer.go +++ b/internal/cmd/commands/indexer/indexer.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp-forge/hermes/internal/indexer" "github.com/hashicorp-forge/hermes/pkg/algolia" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" + "github.com/hashicorp/go-hclog" ) type Command struct { @@ -76,6 +77,19 @@ func (c *Command) Run(args []string) int { return 1 } + // Configure logger. + switch cfg.LogFormat { + case "json": + log = hclog.New(&hclog.LoggerOptions{ + JSONFormat: true, + }) + case "standard": + case "": + default: + ui.Error(fmt.Sprintf("invalid value for log format: %s", cfg.LogFormat)) + return 1 + } + // Initialize database connection. db, err := db.NewDB(*cfg.Postgres) if err != nil { diff --git a/internal/cmd/commands/server/server.go b/internal/cmd/commands/server/server.go index c66fae7a4..b5f0731c1 100644 --- a/internal/cmd/commands/server/server.go +++ b/internal/cmd/commands/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/hashicorp-forge/hermes/pkg/links" "github.com/hashicorp-forge/hermes/pkg/models" "github.com/hashicorp-forge/hermes/web" + "github.com/hashicorp/go-hclog" "gorm.io/gorm" ) @@ -158,6 +159,19 @@ func (c *Command) Run(args []string) int { } } + // Configure logger. + switch cfg.LogFormat { + case "json": + c.Log = hclog.New(&hclog.LoggerOptions{ + JSONFormat: true, + }) + case "standard": + case "": + default: + c.UI.Error(fmt.Sprintf("invalid value for log format: %s", cfg.LogFormat)) + return 1 + } + // Build configuration for Okta authentication. if !cfg.Okta.Disabled { // Check for required Okta configuration. diff --git a/internal/config/config.go b/internal/config/config.go index ad6bee488..c70cb93ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,10 @@ type Config struct { // Indexer contains the configuration for the Hermes indexer. Indexer *Indexer `hcl:"indexer,block"` + // LogFormat configures the logging format. Supported values are "standard" or + // "json". + LogFormat string `hcl:"log_format,optional"` + // Okta configures Hermes to work with Okta. Okta *oktaalb.Config `hcl:"okta,block"` diff --git a/web/app/components/new/doc-form.hbs b/web/app/components/new/doc-form.hbs index 098cf65cf..4b1ab1e91 100644 --- a/web/app/components/new/doc-form.hbs +++ b/web/app/components/new/doc-form.hbs @@ -20,6 +20,7 @@ {{! Title }} { + const document = schema.document.create({ + ...JSON.parse(request.requestBody), + }); + + document.update({ + objectID: document.id, + owners: ["testuser@example.com"], + }); + + return new Response(200, {}, document.attrs); + }); + /** * Used when publishing a draft for review. * Updates the document's status and isDraft properties. diff --git a/web/tests/acceptance/authenticated/new/doc-test.ts b/web/tests/acceptance/authenticated/new/doc-test.ts index 7883fd480..7a5091085 100644 --- a/web/tests/acceptance/authenticated/new/doc-test.ts +++ b/web/tests/acceptance/authenticated/new/doc-test.ts @@ -1,9 +1,26 @@ -import { click, visit } from "@ember/test-helpers"; +import { click, fillIn, visit, waitFor } from "@ember/test-helpers"; import { setupApplicationTest } from "ember-qunit"; import { module, test } from "qunit"; import { authenticateSession } from "ember-simple-auth/test-support"; import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support"; import { getPageTitle } from "ember-page-title/test-support"; +import { Response } from "miragejs"; +import RouterService from "@ember/routing/router-service"; +import window from "ember-window-mock"; +import { DRAFT_CREATED_LOCAL_STORAGE_KEY } from "hermes/components/modals/draft-created"; + +// Selectors +const DOC_FORM = "[data-test-new-doc-form]"; +const PRODUCT_SELECT = `${DOC_FORM} [data-test-product-select]`; +const PRODUCT_SELECT_TOGGLE = `${PRODUCT_SELECT} [data-test-x-dropdown-list-toggle-action]`; +const CREATE_BUTTON = `${DOC_FORM} [data-test-create-button]`; +const TITLE_INPUT = `${DOC_FORM} [data-test-title-input]`; +const SUMMARY_INPUT = `${DOC_FORM} [data-test-summary-input]`; +const PRODUCT_SELECT_ITEM = `${PRODUCT_SELECT} [data-test-x-dropdown-list-item]`; +const FIRST_PRODUCT_SELECT_ITEM_BUTTON = `${PRODUCT_SELECT_ITEM}:first-child button`; +const CREATING_NEW_DOC = "[data-test-creating-new-doc]"; +const FLASH_NOTIFICATION = "[data-test-flash-notification]"; +const DRAFT_CREATED_MODAL = "[data-test-draft-created-modal]"; interface AuthenticatedNewDocRouteTestContext extends MirageTestContext {} @@ -36,25 +53,25 @@ module("Acceptance | authenticated/new/doc", function (hooks) { await visit("/new/doc?docType=RFC"); - const toggleSelector = "[data-test-x-dropdown-list-toggle-action]"; const thumbnailBadgeSelector = "[data-test-doc-thumbnail-product-badge]"; - assert.dom(toggleSelector).exists(); - assert.dom(`${toggleSelector} span`).hasText("Select a product/area"); + assert.dom(PRODUCT_SELECT_TOGGLE).exists(); assert - .dom(`${toggleSelector} .flight-icon`) + .dom(`${PRODUCT_SELECT_TOGGLE} span`) + .hasText("Select a product/area"); + assert + .dom(`${PRODUCT_SELECT_TOGGLE} .flight-icon`) .hasAttribute("data-test-icon", "folder"); assert .dom(thumbnailBadgeSelector) .doesNotExist("badge not shown unless a product shortname exists"); - await click(toggleSelector); + await click(PRODUCT_SELECT_TOGGLE); - const listItemSelector = "[data-test-x-dropdown-list-item]"; - const lastItemSelector = `${listItemSelector}:last-child`; + const lastItemSelector = `${PRODUCT_SELECT_ITEM}:last-child`; - assert.dom(listItemSelector).exists({ count: 5 }); + assert.dom(PRODUCT_SELECT_ITEM).exists({ count: 5 }); assert.dom(lastItemSelector).hasText("Terraform TF"); assert .dom(lastItemSelector + " .flight-icon") @@ -62,10 +79,121 @@ module("Acceptance | authenticated/new/doc", function (hooks) { await click(lastItemSelector + " button"); - assert.dom(toggleSelector).hasText("Terraform TF"); + assert.dom(PRODUCT_SELECT_TOGGLE).hasText("Terraform TF"); assert - .dom(toggleSelector + " .flight-icon") + .dom(PRODUCT_SELECT_TOGGLE + " .flight-icon") .hasAttribute("data-test-icon", "terraform"); assert.dom(thumbnailBadgeSelector).exists(); }); + + test("the create button is disabled until the form requirements are met", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + this.server.createList("product", 1); + + await visit("/new/doc?docType=RFC"); + + assert.dom(CREATE_BUTTON).isDisabled(); + + await fillIn(TITLE_INPUT, "Foo"); + + assert.dom(CREATE_BUTTON).isDisabled(); + + await click(PRODUCT_SELECT_TOGGLE); + await click(FIRST_PRODUCT_SELECT_ITEM_BUTTON); + + assert.dom(CREATE_BUTTON).isNotDisabled(); + + await fillIn(TITLE_INPUT, ""); + + assert.dom(CREATE_BUTTON).isDisabled(); + }); + + test("it shows a loading screen while the doc is being created", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + this.server.createList("product", 1); + + await visit("/new/doc?docType=RFC"); + + await fillIn(TITLE_INPUT, "Foo"); + await click(PRODUCT_SELECT_TOGGLE); + await click(FIRST_PRODUCT_SELECT_ITEM_BUTTON); + + const clickPromise = click(CREATE_BUTTON); + + await waitFor(CREATING_NEW_DOC); + + assert.dom(CREATING_NEW_DOC).exists(); + + await clickPromise; + }); + + test("it shows an error screen if the doc creation fails", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + this.server.createList("product", 1); + + this.server.post("/drafts", () => { + return new Response(500); + }); + + await visit("/new/doc?docType=RFC"); + + await fillIn(TITLE_INPUT, "Foo"); + await click(PRODUCT_SELECT_TOGGLE); + await click(FIRST_PRODUCT_SELECT_ITEM_BUTTON); + + const clickPromise = click(CREATE_BUTTON); + + await waitFor(FLASH_NOTIFICATION); + + assert + .dom(FLASH_NOTIFICATION) + .containsText("Error creating document draft"); + + await clickPromise; + }); + + test("it redirects to the doc page if the doc is created successfully", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + this.server.create("product", { + name: "Terraform", + }); + + // Turn off the modal + window.localStorage.setItem(DRAFT_CREATED_LOCAL_STORAGE_KEY, "true"); + + await visit("/new/doc?docType=RFC"); + + await fillIn(TITLE_INPUT, "Foo"); + + await fillIn(SUMMARY_INPUT, "Bar"); + + await click(PRODUCT_SELECT_TOGGLE); + await click(FIRST_PRODUCT_SELECT_ITEM_BUTTON); + + await click(CREATE_BUTTON); + + const routerService = this.owner.lookup("service:router") as RouterService; + + assert.equal(routerService.currentRouteName, "authenticated.document"); + assert.equal(routerService.currentURL, "/document/1?draft=true"); + + assert.dom("[data-test-document-title]").includesText("Foo"); + assert.dom("[data-test-document-summary]").includesText("Bar"); + assert.dom("[data-test-badge-dropdown-list]").includesText("Terraform"); + }); + + test("it shows a confirmation modal when a draft is created", async function (this: AuthenticatedNewDocRouteTestContext, assert) { + // Reset the localStorage item + window.localStorage.removeItem(DRAFT_CREATED_LOCAL_STORAGE_KEY); + + this.server.createList("product", 1); + + await visit("/new/doc?docType=RFC"); + + await fillIn(TITLE_INPUT, "Foo"); + await click(PRODUCT_SELECT_TOGGLE); + await click(FIRST_PRODUCT_SELECT_ITEM_BUTTON); + + await click(CREATE_BUTTON); + + await waitFor(DRAFT_CREATED_MODAL); + + assert.dom(DRAFT_CREATED_MODAL).exists(); + }); });