Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3716afac46 | |||
| 0f63510d0d | |||
| 003dc5cc03 | |||
| 190cd1c713 |
+14
-4
@@ -1669,13 +1669,12 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
}
|
||||
|
||||
score, niceErr := proc.Niceness(a.syscaller)
|
||||
if niceErr != nil && !xerrors.Is(niceErr, os.ErrPermission) {
|
||||
if !isBenignProcessErr(niceErr) {
|
||||
debouncer.Warn(ctx, "unable to get proc niceness",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
slog.Error(niceErr),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// We only want processes that don't have a nice value set
|
||||
@@ -1689,7 +1688,7 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
|
||||
if niceErr == nil {
|
||||
err := proc.SetNiceness(a.syscaller, niceness)
|
||||
if err != nil && !xerrors.Is(err, os.ErrPermission) {
|
||||
if !isBenignProcessErr(err) {
|
||||
debouncer.Warn(ctx, "unable to set proc niceness",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
@@ -1703,7 +1702,7 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
if oomScore != unsetOOMScore && oomScore != proc.OOMScoreAdj && !isCustomOOMScore(agentScore, proc) {
|
||||
oomScoreStr := strconv.Itoa(oomScore)
|
||||
err := afero.WriteFile(a.filesystem, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), []byte(oomScoreStr), 0o644)
|
||||
if err != nil && !xerrors.Is(err, os.ErrPermission) {
|
||||
if !isBenignProcessErr(err) {
|
||||
debouncer.Warn(ctx, "unable to set oom_score_adj",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
@@ -2139,3 +2138,14 @@ func (l *logDebouncer) log(ctx context.Context, level slog.Level, msg string, fi
|
||||
}
|
||||
l.messages[msg] = time.Now()
|
||||
}
|
||||
|
||||
func isBenignProcessErr(err error) bool {
|
||||
return err != nil &&
|
||||
(xerrors.Is(err, os.ErrNotExist) ||
|
||||
xerrors.Is(err, os.ErrPermission) ||
|
||||
isNoSuchProcessErr(err))
|
||||
}
|
||||
|
||||
func isNoSuchProcessErr(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "no such process")
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
|
||||
cmdline, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "cmdline"))
|
||||
if err != nil {
|
||||
var errNo syscall.Errno
|
||||
if xerrors.As(err, &errNo) && errNo == syscall.EPERM {
|
||||
if isBenignError(err) {
|
||||
continue
|
||||
}
|
||||
return nil, xerrors.Errorf("read cmdline: %w", err)
|
||||
@@ -54,7 +53,7 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
|
||||
oomScore, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "oom_score_adj"))
|
||||
if err != nil {
|
||||
if xerrors.Is(err, os.ErrPermission) {
|
||||
if isBenignError(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -124,3 +123,12 @@ func (p *Process) Cmd() string {
|
||||
func (p *Process) cmdLine() []string {
|
||||
return strings.Split(p.CmdLine, "\x00")
|
||||
}
|
||||
|
||||
func isBenignError(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if !xerrors.As(err, &errno) {
|
||||
return false
|
||||
}
|
||||
|
||||
return errno == syscall.ESRCH || errno == syscall.EPERM || xerrors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
+9
-10
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -109,9 +108,9 @@ func TestCreateUserRequestJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := codersdk.CreateUserRequestWithOrgs{
|
||||
Email: coderdtest.RandomName(t),
|
||||
Username: coderdtest.RandomName(t),
|
||||
Name: coderdtest.RandomName(t),
|
||||
Email: "alice@coder.com",
|
||||
Username: "alice",
|
||||
Name: "Alice User",
|
||||
Password: "",
|
||||
UserLoginType: codersdk.LoginTypePassword,
|
||||
OrganizationIDs: []uuid.UUID{uuid.New(), uuid.New()},
|
||||
@@ -123,9 +122,9 @@ func TestCreateUserRequestJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := codersdk.CreateUserRequestWithOrgs{
|
||||
Email: coderdtest.RandomName(t),
|
||||
Username: coderdtest.RandomName(t),
|
||||
Name: coderdtest.RandomName(t),
|
||||
Email: "alice@coder.com",
|
||||
Username: "alice",
|
||||
Name: "Alice User",
|
||||
Password: "",
|
||||
UserLoginType: codersdk.LoginTypePassword,
|
||||
OrganizationIDs: []uuid.UUID{uuid.New()},
|
||||
@@ -137,9 +136,9 @@ func TestCreateUserRequestJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := codersdk.CreateUserRequestWithOrgs{
|
||||
Email: coderdtest.RandomName(t),
|
||||
Username: coderdtest.RandomName(t),
|
||||
Name: coderdtest.RandomName(t),
|
||||
Email: "alice@coder.com",
|
||||
Username: "alice",
|
||||
Name: "Alice User",
|
||||
Password: "",
|
||||
UserLoginType: codersdk.LoginTypePassword,
|
||||
OrganizationIDs: []uuid.UUID{},
|
||||
|
||||
@@ -77,9 +77,9 @@ can only be delivered to one method, and this method is configured globally with
|
||||
[`CODER_NOTIFICATIONS_METHOD`](https://coder.com/docs/reference/cli/server#--notifications-method)
|
||||
(default: `smtp`).
|
||||
|
||||
Enterprise customers can configured which method to use for each of the
|
||||
supported [Events](#events); see the [Preferences](#preferences) section below
|
||||
for more details.
|
||||
Enterprise customers can configure which method to use for each of the supported
|
||||
[Events](#events); see the [Preferences](#preferences) section below for more
|
||||
details.
|
||||
|
||||
## SMTP (Email)
|
||||
|
||||
@@ -93,7 +93,7 @@ existing one.
|
||||
| :------: | --------------------------------- | ------------------------------------- | ----------- | ----------------------------------------- | ------------- |
|
||||
| ✔️ | `--notifications-email-from` | `CODER_NOTIFICATIONS_EMAIL_FROM` | `string` | The sender's address to use. | |
|
||||
| ✔️ | `--notifications-email-smarthost` | `CODER_NOTIFICATIONS_EMAIL_SMARTHOST` | `host:port` | The SMTP relay to send messages through. | localhost:587 |
|
||||
| -️ | `--notifications-email-hello` | `CODER_NOTIFICATIONS_EMAIL_HELLO` | `string` | The hostname identifying the SMTP server. | localhost |
|
||||
| ✔️ | `--notifications-email-hello` | `CODER_NOTIFICATIONS_EMAIL_HELLO` | `string` | The hostname identifying the SMTP server. | localhost |
|
||||
|
||||
**Authentication Settings:**
|
||||
|
||||
@@ -252,6 +252,18 @@ To pause sending notifications, execute
|
||||
To resume sending notifications, execute
|
||||
[`coder notifications resume`](https://coder.com/docs/reference/cli/notifications_resume).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If notifications are not being delivered, use the following methods to
|
||||
troubleshoot:
|
||||
|
||||
1. Ensure notifications are being added to the `notification_messages` table
|
||||
2. Review any error messages in the `status_reason` column, should an error have
|
||||
occurred
|
||||
3. Review the logs (search for the term `notifications`) for diagnostic
|
||||
information<br> _If you do not see any relevant logs, set
|
||||
`CODER_VERBOSE=true` or `--verbose` to output debug logs_
|
||||
|
||||
## Internals
|
||||
|
||||
The notification system is built to operate concurrently in a single- or
|
||||
@@ -288,5 +300,4 @@ messages._
|
||||
- after `CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS` is exceeded, it transitions to
|
||||
`permanent_failure`
|
||||
|
||||
Diagnostic messages will be saved in the `notification_messages` table and will
|
||||
be logged, in the case of failure.
|
||||
See [Troubleshooting](#troubleshooting) above for more details.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 71 KiB |
@@ -174,6 +174,10 @@ type LicenseOptions struct {
|
||||
// ExpiresAt is the time at which the license will hard expire.
|
||||
// ExpiresAt should always be greater then GraceAt.
|
||||
ExpiresAt time.Time
|
||||
// NotBefore is the time at which the license becomes valid. If set to the
|
||||
// zero value, the `nbf` claim on the license is set to 1 minute in the
|
||||
// past.
|
||||
NotBefore time.Time
|
||||
Features license.Features
|
||||
}
|
||||
|
||||
@@ -195,6 +199,13 @@ func (opts *LicenseOptions) Valid(now time.Time) *LicenseOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
func (opts *LicenseOptions) FutureTerm(now time.Time) *LicenseOptions {
|
||||
opts.NotBefore = now.Add(time.Hour * 24)
|
||||
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
|
||||
opts.GraceAt = now.Add(time.Hour * 24 * 53)
|
||||
return opts
|
||||
}
|
||||
|
||||
func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
|
||||
return opts.Feature(codersdk.FeatureUserLimit, limit)
|
||||
}
|
||||
@@ -233,13 +244,16 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
if options.GraceAt.IsZero() {
|
||||
options.GraceAt = time.Now().Add(time.Hour)
|
||||
}
|
||||
if options.NotBefore.IsZero() {
|
||||
options.NotBefore = time.Now().Add(-time.Minute)
|
||||
}
|
||||
|
||||
c := &license.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.NewString(),
|
||||
Issuer: "test@testing.test",
|
||||
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(options.NotBefore),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
},
|
||||
LicenseExpires: jwt.NewNumericDate(options.GraceAt),
|
||||
|
||||
@@ -100,6 +100,13 @@ func LicensesEntitlements(
|
||||
// 'Entitlements' group as a whole.
|
||||
for _, license := range licenses {
|
||||
claims, err := ParseClaims(license.JWT, keys)
|
||||
var vErr *jwt.ValidationError
|
||||
if xerrors.As(err, &vErr) && vErr.Is(jwt.ErrTokenNotValidYet) {
|
||||
// The license isn't valid yet. We don't consider any entitlements contained in it, but
|
||||
// it's also not an error. Just skip it silently. This can happen if an administrator
|
||||
// uploads a license for a new term that hasn't started yet.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
entitlements.Errors = append(entitlements.Errors,
|
||||
fmt.Sprintf("Invalid license (%s) parsing claims: %s", license.UUID.String(), err.Error()))
|
||||
@@ -287,6 +294,8 @@ var (
|
||||
ErrInvalidVersion = xerrors.New("license must be version 3")
|
||||
ErrMissingKeyID = xerrors.Errorf("JOSE header must contain %s", HeaderKeyID)
|
||||
ErrMissingLicenseExpires = xerrors.New("license missing license_expires")
|
||||
ErrMissingExp = xerrors.New("exp claim missing or not parsable")
|
||||
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
|
||||
)
|
||||
|
||||
type Features map[codersdk.FeatureName]int64
|
||||
@@ -336,7 +345,7 @@ func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error
|
||||
return nil, xerrors.New("unable to parse Claims")
|
||||
}
|
||||
|
||||
// ParseClaims validates a database.License record, and if valid, returns the claims. If
|
||||
// ParseClaims validates a raw JWT, and if valid, returns the claims. If
|
||||
// unparsable or invalid, it returns an error
|
||||
func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
|
||||
tok, err := jwt.ParseWithClaims(
|
||||
@@ -348,18 +357,53 @@ func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := tok.Claims.(*Claims); ok && tok.Valid {
|
||||
return validateClaims(tok)
|
||||
}
|
||||
|
||||
func validateClaims(tok *jwt.Token) (*Claims, error) {
|
||||
if claims, ok := tok.Claims.(*Claims); ok {
|
||||
if claims.Version != uint64(CurrentVersion) {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
if claims.LicenseExpires == nil {
|
||||
return nil, ErrMissingLicenseExpires
|
||||
}
|
||||
if claims.ExpiresAt == nil {
|
||||
return nil, ErrMissingExp
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
return nil, xerrors.New("unable to parse Claims")
|
||||
}
|
||||
|
||||
// ParseClaimsIgnoreNbf validates a raw JWT, but ignores `nbf` claim. If otherwise valid, it returns
|
||||
// the claims. If unparsable or invalid, it returns an error. Ignoring the `nbf` (not before) is
|
||||
// useful to determine if a JWT _will_ become valid at any point now or in the future.
|
||||
func ParseClaimsIgnoreNbf(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
|
||||
tok, err := jwt.ParseWithClaims(
|
||||
rawJWT,
|
||||
&Claims{},
|
||||
keyFunc(keys),
|
||||
jwt.WithValidMethods(ValidMethods),
|
||||
)
|
||||
var vErr *jwt.ValidationError
|
||||
if xerrors.As(err, &vErr) {
|
||||
// zero out the NotValidYet error to check if there were other problems
|
||||
vErr.Errors = vErr.Errors & (^jwt.ValidationErrorNotValidYet)
|
||||
if vErr.Errors != 0 {
|
||||
// There are other errors besides not being valid yet. We _could_ go
|
||||
// through all the jwt.ValidationError bits and try to work out the
|
||||
// correct error, but if we get here something very strange is
|
||||
// going on so let's just return a generic error that says to get in
|
||||
// touch with our support team.
|
||||
return nil, ErrMultipleIssues
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return validateClaims(tok)
|
||||
}
|
||||
|
||||
func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, error) {
|
||||
return func(j *jwt.Token) (interface{}, error) {
|
||||
keyID, ok := j.Header[HeaderKeyID].(string)
|
||||
|
||||
@@ -826,6 +826,25 @@ func TestLicenseEntitlements(t *testing.T) {
|
||||
assert.True(t, entitlements.Features[codersdk.FeatureCustomRoles].Enabled, "custom-roles enabled for premium")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "CurrentAndFuture",
|
||||
Licenses: []*coderdenttest.LicenseOptions{
|
||||
enterpriseLicense().UserLimit(100),
|
||||
premiumLicense().UserLimit(200).FutureTerm(time.Now()),
|
||||
},
|
||||
Enablements: defaultEnablements,
|
||||
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
|
||||
assertEnterpriseFeatures(t, entitlements)
|
||||
assertNoErrors(t, entitlements)
|
||||
assertNoWarnings(t, entitlements)
|
||||
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
|
||||
assert.Equalf(t, int64(100), *userFeature.Limit, "user limit")
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureMultipleOrganizations].Entitlement)
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureCustomRoles].Entitlement)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
@@ -86,25 +86,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rawClaims, err := license.ParseRaw(addLicense.License, api.LicenseKeys)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
exp, ok := rawClaims["exp"].(float64)
|
||||
if !ok {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
Detail: "exp claim missing or not parsable",
|
||||
})
|
||||
return
|
||||
}
|
||||
expTime := time.Unix(int64(exp), 0)
|
||||
|
||||
claims, err := license.ParseClaims(addLicense.License, api.LicenseKeys)
|
||||
claims, err := license.ParseClaimsIgnoreNbf(addLicense.License, api.LicenseKeys)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
@@ -134,7 +116,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{
|
||||
UploadedAt: dbtime.Now(),
|
||||
JWT: addLicense.License,
|
||||
Exp: expTime,
|
||||
Exp: claims.ExpiresAt.Time,
|
||||
UUID: id,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -160,7 +142,15 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
// don't fail the HTTP request, since we did write it successfully to the database
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims))
|
||||
c, err := decodeClaims(dl)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to decode database response",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, c))
|
||||
}
|
||||
|
||||
// postRefreshEntitlements forces an `updateEntitlements` call and publishes
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -82,6 +83,53 @@ func TestPostLicense(t *testing.T) {
|
||||
t.Error("expected to get error status 400")
|
||||
}
|
||||
})
|
||||
|
||||
// Test a license that isn't yet valid, but will be in the future. We should allow this so that
|
||||
// operators can upload a license ahead of time.
|
||||
t.Run("NotYet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
|
||||
respLic := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
AccountType: license.AccountTypeSalesforce,
|
||||
AccountID: "testing",
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAuditLog: 1,
|
||||
},
|
||||
NotBefore: time.Now().Add(time.Hour),
|
||||
GraceAt: time.Now().Add(2 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(3 * time.Hour),
|
||||
})
|
||||
assert.GreaterOrEqual(t, respLic.ID, int32(0))
|
||||
// just a couple spot checks for sanity
|
||||
assert.Equal(t, "testing", respLic.Claims["account_id"])
|
||||
features, err := respLic.FeaturesClaims()
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
|
||||
})
|
||||
|
||||
// Test we still reject a license that isn't valid yet, but has other issues (e.g. expired
|
||||
// before it starts).
|
||||
t.Run("NotEver", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
|
||||
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
AccountType: license.AccountTypeSalesforce,
|
||||
AccountID: "testing",
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAuditLog: 1,
|
||||
},
|
||||
NotBefore: time.Now().Add(time.Hour),
|
||||
GraceAt: time.Now().Add(2 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
})
|
||||
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
|
||||
License: lic,
|
||||
})
|
||||
errResp := &codersdk.Error{}
|
||||
require.ErrorAs(t, err, &errResp)
|
||||
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
|
||||
require.Contains(t, errResp.Detail, license.ErrMultipleIssues.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLicense(t *testing.T) {
|
||||
|
||||
+2
-1
@@ -154,7 +154,8 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
|
||||
<FormFooter
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
submitDisabled={disabled}
|
||||
submitLabel="Submit and restart"
|
||||
submitDisabled={disabled || !form.dirty}
|
||||
/>
|
||||
</HorizontalForm>
|
||||
</>
|
||||
|
||||
+58
-1
@@ -61,7 +61,9 @@ test("Submit the workspace settings page successfully", async () => {
|
||||
);
|
||||
await user.clear(parameter2);
|
||||
await user.type(parameter2, "1");
|
||||
await user.click(within(form).getByRole("button", { name: "Submit" }));
|
||||
await user.click(
|
||||
within(form).getByRole("button", { name: "Submit and restart" }),
|
||||
);
|
||||
// Assert that the API calls were made with the correct data
|
||||
await waitFor(() => {
|
||||
expect(postWorkspaceBuildSpy).toHaveBeenCalledWith(MockWorkspace.id, {
|
||||
@@ -73,3 +75,58 @@ test("Submit the workspace settings page successfully", async () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Submit button is only enabled when changes are made", async () => {
|
||||
// Mock the API calls that loads data
|
||||
jest
|
||||
.spyOn(API, "getWorkspaceByOwnerAndName")
|
||||
.mockResolvedValueOnce(MockWorkspace);
|
||||
jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
|
||||
MockTemplateVersionParameter1,
|
||||
MockTemplateVersionParameter2,
|
||||
// Immutable parameters
|
||||
MockTemplateVersionParameter4,
|
||||
]);
|
||||
jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([
|
||||
MockWorkspaceBuildParameter1,
|
||||
MockWorkspaceBuildParameter2,
|
||||
// Immutable value
|
||||
MockWorkspaceBuildParameter4,
|
||||
]);
|
||||
// Setup event and rendering
|
||||
const user = userEvent.setup();
|
||||
renderWithWorkspaceSettingsLayout(<WorkspaceParametersPage />, {
|
||||
route: "/@test-user/test-workspace/settings",
|
||||
path: "/:username/:workspace/settings",
|
||||
// Need this because after submit the user is redirected
|
||||
extraRoutes: [{ path: "/:username/:workspace", element: <div /> }],
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
const submitButton: HTMLButtonElement = screen.getByRole("button", {
|
||||
name: "Submit and restart",
|
||||
});
|
||||
|
||||
const form = screen.getByTestId("form");
|
||||
const parameter1 = within(form).getByLabelText(
|
||||
MockWorkspaceBuildParameter1.name,
|
||||
{ exact: false },
|
||||
);
|
||||
|
||||
// There are no changes, the button should be disabled.
|
||||
expect(submitButton.disabled).toBeTruthy();
|
||||
|
||||
// Make changes to the form
|
||||
await user.clear(parameter1);
|
||||
await user.type(parameter1, "new-value");
|
||||
|
||||
// There are now changes, the button should be enabled.
|
||||
expect(submitButton.disabled).toBeFalsy();
|
||||
|
||||
// Change form value back to default
|
||||
await user.clear(parameter1);
|
||||
await user.type(parameter1, MockWorkspaceBuildParameter1.value);
|
||||
|
||||
// There are now no changes, the button should be disabled.
|
||||
expect(submitButton.disabled).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"confluence.svg",
|
||||
"container.svg",
|
||||
"cpp.svg",
|
||||
"cursor.svg",
|
||||
"database.svg",
|
||||
"datagrip.svg",
|
||||
"dataspell.svg",
|
||||
|
||||
@@ -9,7 +9,7 @@ function defaultDocsUrl(): string {
|
||||
}
|
||||
|
||||
// Strip the postfix version info that's not part of the link.
|
||||
const i = version?.indexOf("-") ?? -1;
|
||||
const i = version?.match(/[+-]/)?.index ?? -1;
|
||||
if (i >= 0) {
|
||||
version = version.slice(0, i);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.5 MiB |
Reference in New Issue
Block a user