@@ -2,9 +2,18 @@ package cli_test
2
2
3
3
import (
4
4
"context"
5
+ "database/sql"
5
6
"fmt"
6
7
"io"
7
8
"testing"
9
+ "time"
10
+
11
+ "github.com/google/uuid"
12
+
13
+ "github.com/coder/coder/v2/coderd/database"
14
+ "github.com/coder/coder/v2/coderd/database/dbgen"
15
+ "github.com/coder/coder/v2/coderd/database/pubsub"
16
+ "github.com/coder/quartz"
8
17
9
18
"github.com/stretchr/testify/assert"
10
19
"github.com/stretchr/testify/require"
@@ -209,4 +218,225 @@ func TestDelete(t *testing.T) {
209
218
cancel ()
210
219
<- doneChan
211
220
})
221
+
222
+ t .Run ("Prebuilt workspace delete permissions" ,func (t * testing.T ) {
223
+ t .Parallel ()
224
+ if ! dbtestutil .WillUsePostgres () {
225
+ t .Skip ("this test requires postgres" )
226
+ }
227
+
228
+ clock := quartz .NewMock (t )
229
+ ctx := testutil .Context (t ,testutil .WaitSuperLong )
230
+
231
+ // Setup
232
+ db ,pb := dbtestutil .NewDB (t ,dbtestutil .WithDumpOnFailure ())
233
+ client ,_ := coderdtest .NewWithProvisionerCloser (t ,& coderdtest.Options {
234
+ Database :db ,
235
+ Pubsub :pb ,
236
+ IncludeProvisionerDaemon :true ,
237
+ })
238
+ owner := coderdtest .CreateFirstUser (t ,client )
239
+ orgID := owner .OrganizationID
240
+
241
+ // Given a template version with a preset and a template
242
+ version := coderdtest .CreateTemplateVersion (t ,client ,orgID ,nil )
243
+ coderdtest .AwaitTemplateVersionJobCompleted (t ,client ,version .ID )
244
+ preset := setupTestDBPreset (t ,db ,version .ID )
245
+ template := coderdtest .CreateTemplate (t ,client ,orgID ,version .ID )
246
+
247
+ cases := []struct {
248
+ name string
249
+ client * codersdk.Client
250
+ expectedPrebuiltDeleteErrMsg string
251
+ expectedWorkspaceDeleteErrMsg string
252
+ }{
253
+ // Users with the OrgAdmin role should be able to delete both normal and prebuilt workspaces
254
+ {
255
+ name :"OrgAdmin" ,
256
+ client :func ()* codersdk.Client {
257
+ client ,_ := coderdtest .CreateAnotherUser (t ,client ,orgID ,rbac .ScopedRoleOrgAdmin (orgID ))
258
+ return client
259
+ }(),
260
+ },
261
+ // Users with the TemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
262
+ {
263
+ name :"TemplateAdmin" ,
264
+ client :func ()* codersdk.Client {
265
+ client ,_ := coderdtest .CreateAnotherUser (t ,client ,orgID ,rbac .RoleTemplateAdmin ())
266
+ return client
267
+ }(),
268
+ expectedWorkspaceDeleteErrMsg :"unexpected status code 403: You do not have permission to delete this workspace." ,
269
+ },
270
+ // Users with the OrgTemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
271
+ {
272
+ name :"OrgTemplateAdmin" ,
273
+ client :func ()* codersdk.Client {
274
+ client ,_ := coderdtest .CreateAnotherUser (t ,client ,orgID ,rbac .ScopedRoleOrgTemplateAdmin (orgID ))
275
+ return client
276
+ }(),
277
+ expectedWorkspaceDeleteErrMsg :"unexpected status code 403: You do not have permission to delete this workspace." ,
278
+ },
279
+ // Users with the Member role should not be able to delete prebuilt or normal workspaces
280
+ {
281
+ name :"Member" ,
282
+ client :func ()* codersdk.Client {
283
+ client ,_ := coderdtest .CreateAnotherUser (t ,client ,orgID ,rbac .RoleMember ())
284
+ return client
285
+ }(),
286
+ expectedPrebuiltDeleteErrMsg :"unexpected status code 404: Resource not found or you do not have access to this resource" ,
287
+ expectedWorkspaceDeleteErrMsg :"unexpected status code 404: Resource not found or you do not have access to this resource" ,
288
+ },
289
+ }
290
+
291
+ for _ ,tc := range cases {
292
+ tc := tc
293
+ t .Run (tc .name ,func (t * testing.T ) {
294
+ t .Parallel ()
295
+
296
+ // Create one prebuilt workspace (owned by system user) and one normal workspace (owned by a user)
297
+ // Each workspace is persisted in the DB along with associated workspace jobs and builds.
298
+ dbPrebuiltWorkspace := setupTestDBWorkspace (t ,clock ,db ,pb ,orgID ,database .PrebuildsSystemUserID ,template .ID ,version .ID ,preset .ID )
299
+ userWorkspaceOwner ,err := client .User (context .Background (),"testUser" )
300
+ require .NoError (t ,err )
301
+ dbUserWorkspace := setupTestDBWorkspace (t ,clock ,db ,pb ,orgID ,userWorkspaceOwner .ID ,template .ID ,version .ID ,preset .ID )
302
+
303
+ assertWorkspaceDelete := func (
304
+ runClient * codersdk.Client ,
305
+ workspace database.Workspace ,
306
+ workspaceOwner string ,
307
+ expectedErr string ,
308
+ ) {
309
+ t .Helper ()
310
+
311
+ // Attempt to delete the workspace as the test client
312
+ inv ,root := clitest .New (t ,"delete" ,workspaceOwner + "/" + workspace .Name ,"-y" )
313
+ clitest .SetupConfig (t ,runClient ,root )
314
+ doneChan := make (chan struct {})
315
+ pty := ptytest .New (t ).Attach (inv )
316
+ var runErr error
317
+ go func () {
318
+ defer close (doneChan )
319
+ runErr = inv .Run ()
320
+ }()
321
+
322
+ // Validate the result based on the expected error message
323
+ if expectedErr != "" {
324
+ <- doneChan
325
+ require .Error (t ,runErr )
326
+ require .Contains (t ,runErr .Error (),expectedErr )
327
+ }else {
328
+ pty .ExpectMatch ("has been deleted" )
329
+ <- doneChan
330
+
331
+ // When running with the race detector on, we sometimes get an EOF.
332
+ if runErr != nil {
333
+ assert .ErrorIs (t ,runErr ,io .EOF )
334
+ }
335
+
336
+ // Verify that the workspace is now marked as deleted
337
+ _ ,err := client .Workspace (context .Background (),workspace .ID )
338
+ require .ErrorContains (t ,err ,"was deleted" )
339
+ }
340
+ }
341
+
342
+ // Ensure at least one prebuilt workspace is reported as running in the database
343
+ testutil .Eventually (ctx ,t ,func (ctx context.Context ) (done bool ) {
344
+ running ,err := db .GetRunningPrebuiltWorkspaces (ctx )
345
+ if ! assert .NoError (t ,err )|| ! assert .GreaterOrEqual (t ,len (running ),1 ) {
346
+ return false
347
+ }
348
+ return true
349
+ },testutil .IntervalMedium ,"running prebuilt workspaces timeout" )
350
+
351
+ runningWorkspaces ,err := db .GetRunningPrebuiltWorkspaces (ctx )
352
+ require .NoError (t ,err )
353
+ require .GreaterOrEqual (t ,len (runningWorkspaces ),1 )
354
+
355
+ // Get the full prebuilt workspace object from the DB
356
+ prebuiltWorkspace ,err := db .GetWorkspaceByID (ctx ,dbPrebuiltWorkspace .ID )
357
+ require .NoError (t ,err )
358
+
359
+ // Assert the prebuilt workspace deletion
360
+ assertWorkspaceDelete (tc .client ,prebuiltWorkspace ,"prebuilds" ,tc .expectedPrebuiltDeleteErrMsg )
361
+
362
+ // Get the full user workspace object from the DB
363
+ userWorkspace ,err := db .GetWorkspaceByID (ctx ,dbUserWorkspace .ID )
364
+ require .NoError (t ,err )
365
+
366
+ // Assert the user workspace deletion
367
+ assertWorkspaceDelete (tc .client ,userWorkspace ,userWorkspaceOwner .Username ,tc .expectedWorkspaceDeleteErrMsg )
368
+ })
369
+ }
370
+ })
371
+ }
372
+
373
+ func setupTestDBPreset (
374
+ t * testing.T ,
375
+ db database.Store ,
376
+ templateVersionID uuid.UUID ,
377
+ ) database.TemplateVersionPreset {
378
+ t .Helper ()
379
+
380
+ preset := dbgen .Preset (t ,db , database.InsertPresetParams {
381
+ TemplateVersionID :templateVersionID ,
382
+ Name :"preset-test" ,
383
+ DesiredInstances : sql.NullInt32 {
384
+ Valid :true ,
385
+ Int32 :1 ,
386
+ },
387
+ })
388
+ dbgen .PresetParameter (t ,db , database.InsertPresetParametersParams {
389
+ TemplateVersionPresetID :preset .ID ,
390
+ Names : []string {"test" },
391
+ Values : []string {"test" },
392
+ })
393
+
394
+ return preset
395
+ }
396
+
397
+ func setupTestDBWorkspace (
398
+ t * testing.T ,
399
+ clock quartz.Clock ,
400
+ db database.Store ,
401
+ ps pubsub.Pubsub ,
402
+ orgID uuid.UUID ,
403
+ ownerID uuid.UUID ,
404
+ templateID uuid.UUID ,
405
+ templateVersionID uuid.UUID ,
406
+ presetID uuid.UUID ,
407
+ ) database.WorkspaceTable {
408
+ t .Helper ()
409
+
410
+ workspace := dbgen .Workspace (t ,db , database.WorkspaceTable {
411
+ TemplateID :templateID ,
412
+ OrganizationID :orgID ,
413
+ OwnerID :ownerID ,
414
+ Deleted :false ,
415
+ CreatedAt :time .Now ().Add (- time .Hour * 2 ),
416
+ })
417
+ job := dbgen .ProvisionerJob (t ,db ,ps , database.ProvisionerJob {
418
+ InitiatorID :ownerID ,
419
+ CreatedAt :time .Now ().Add (- time .Hour * 2 ),
420
+ StartedAt : sql.NullTime {Time :clock .Now ().Add (- time .Hour * 2 ),Valid :true },
421
+ CompletedAt : sql.NullTime {Time :clock .Now ().Add (- time .Hour ),Valid :true },
422
+ OrganizationID :orgID ,
423
+ })
424
+ workspaceBuild := dbgen .WorkspaceBuild (t ,db , database.WorkspaceBuild {
425
+ WorkspaceID :workspace .ID ,
426
+ InitiatorID :ownerID ,
427
+ TemplateVersionID :templateVersionID ,
428
+ JobID :job .ID ,
429
+ TemplateVersionPresetID : uuid.NullUUID {UUID :presetID ,Valid :true },
430
+ Transition :database .WorkspaceTransitionStart ,
431
+ CreatedAt :clock .Now (),
432
+ })
433
+ dbgen .WorkspaceBuildParameters (t ,db , []database.WorkspaceBuildParameter {
434
+ {
435
+ WorkspaceBuildID :workspaceBuild .ID ,
436
+ Name :"test" ,
437
+ Value :"test" ,
438
+ },
439
+ })
440
+
441
+ return workspace
212
442
}