integration_test.go 15.2 KB
Newer Older
ale's avatar
ale committed
1 2 3 4
package integrationtest

import (
	"bytes"
5
	"context"
ale's avatar
ale committed
6
	"encoding/json"
7
	"errors"
ale's avatar
ale committed
8 9 10 11 12 13 14 15 16
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"time"

17
	as "git.autistici.org/ai3/accountserver"
ale's avatar
ale committed
18 19 20
	"git.autistici.org/ai3/accountserver/backend"
	"git.autistici.org/ai3/accountserver/ldaptest"
	"git.autistici.org/ai3/accountserver/server"
21 22
	"git.autistici.org/ai3/go-common/pwhash"
	"git.autistici.org/ai3/go-common/userenckey"
23
	"git.autistici.org/id/go-sso"
ale's avatar
ale committed
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
	"golang.org/x/crypto/ed25519"
)

const (
	testLDAPPort = 42872
	testLDAPAddr = "ldap://127.0.0.1:42872"

	testSSODomain  = "domain"
	testSSOService = "accountserver.domain/"
	testAdminUser  = "admin"
	testAdminGroup = "admins"
)

func withSSO(t testing.TB) (func(), sso.Signer, string) {
	tmpf, err := ioutil.TempFile("", "")
	if err != nil {
		t.Fatal(err)
	}

	pub, priv, err := ed25519.GenerateKey(nil)
	if err != nil {
		t.Fatal(err)
	}
	tmpf.Write(pub)
	tmpf.Close()

	signer, err := sso.NewSigner(priv)
	if err != nil {
		t.Fatal(err)
	}

	return func() {
		os.Remove(tmpf.Name())
	}, signer, tmpf.Name()
}

type testClient struct {
	srvURL string
	signer sso.Signer
}

65 66
func (c *testClient) ssoTicket(username string, groups ...string) string {
	if len(groups) == 0 && username == testAdminUser {
ale's avatar
ale committed
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
		groups = append(groups, testAdminGroup)
	}
	signed, err := c.signer.Sign(sso.NewTicket(username, testSSOService, testSSODomain, "", groups, 1*time.Hour))
	if err != nil {
		panic(err)
	}
	return signed
}

func (c *testClient) request(uri string, req, out interface{}) error {
	data, _ := json.Marshal(req)
	resp, err := http.Post(c.srvURL+uri, "application/json", bytes.NewReader(data))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
83 84 85 86 87 88
	data, _ = ioutil.ReadAll(resp.Body)

	if resp.StatusCode >= 400 && resp.StatusCode < 500 {
		log.Printf("request error: %s", string(data))
		return errors.New(string(data))
	}
ale's avatar
ale committed
89
	if resp.StatusCode != 200 {
90
		log.Printf("remote error: %s", string(data))
ale's avatar
ale committed
91 92 93 94 95 96 97 98 99 100 101 102 103 104
		return fmt.Errorf("http status code %d", resp.StatusCode)
	}
	if resp.Header.Get("Content-Type") != "application/json" {
		return fmt.Errorf("unexpected content-type %s", resp.Header.Get("Content-Type"))
	}

	log.Printf("response:\n%s\n", string(data))

	if out == nil {
		return nil
	}
	return json.Unmarshal(data, out)
}

105
func startServiceWithConfig(t testing.TB, svcConfig as.Config) (func(), as.Backend, *testClient) {
ale's avatar
ale committed
106
	stop := ldaptest.StartServer(t, &ldaptest.Config{
107 108 109 110 111 112 113 114
		Dir:  "../ldaptest",
		Port: testLDAPPort,
		Base: "dc=example,dc=com",
		LDIFs: []string{
			"testdata/base.ldif",
			"testdata/test1.ldif",
			"testdata/test2.ldif",
		},
ale's avatar
ale committed
115 116 117 118 119 120 121 122 123 124 125 126 127 128
	})

	be, err := backend.NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
	if err != nil {
		t.Fatal("NewLDAPBackend", err)
	}

	ssoStop, signer, ssoPubKeyFile := withSSO(t)

	svcConfig.SSO.PublicKeyFile = ssoPubKeyFile
	svcConfig.SSO.Domain = testSSODomain
	svcConfig.SSO.Service = testSSOService
	svcConfig.SSO.AdminGroup = testAdminGroup
	svcConfig.AvailableDomains = map[string][]string{
129
		as.ResourceTypeEmail: []string{"example.com"},
ale's avatar
ale committed
130
	}
ale's avatar
ale committed
131
	shards := []string{"host1", "host2", "host3"}
132
	svcConfig.Shards.Available = map[string][]string{
133 134 135 136 137
		as.ResourceTypeEmail:    shards,
		as.ResourceTypeWebsite:  shards,
		as.ResourceTypeDomain:   shards,
		as.ResourceTypeDAV:      shards,
		as.ResourceTypeDatabase: shards,
138 139
	}
	svcConfig.Shards.Allowed = svcConfig.Shards.Available
ale's avatar
ale committed
140
	svcConfig.WebsiteRootDir = "/home/users/investici.org"
ale's avatar
ale committed
141

142
	service, err := as.NewAccountService(be, &svcConfig)
ale's avatar
ale committed
143 144 145 146 147 148
	if err != nil {
		stop()
		t.Fatal("NewAccountService", err)
	}

	as := server.New(service, be)
149
	srv := httptest.NewServer(as)
ale's avatar
ale committed
150 151 152 153 154 155 156 157 158 159

	c := &testClient{
		srvURL: srv.URL,
		signer: signer,
	}

	return func() {
		stop()
		srv.Close()
		ssoStop()
160
	}, be, c
ale's avatar
ale committed
161 162
}

163 164 165 166
func startService(t testing.TB) (func(), as.Backend, *testClient) {
	return startServiceWithConfig(t, as.Config{})
}

ale's avatar
ale committed
167 168 169 170
// Verify that authentication on GetUser works as expected:
// - users can fetch their own data but not other users'
// - admins can read everything.
func TestIntegration_GetUser_Auth(t *testing.T) {
171
	stop, _, c := startService(t)
ale's avatar
ale committed
172 173 174 175
	defer stop()

	testdata := []struct {
		authUser   string
176
		authGroup  string
ale's avatar
ale committed
177 178
		expectedOk bool
	}{
179 180 181 182
		{"uno@investici.org", "", true},
		{"uno@investici.org", "users", true},
		{"due@investici.org", "users", false},
		{testAdminUser, testAdminGroup, true},
ale's avatar
ale committed
183 184 185
	}

	for _, td := range testdata {
186
		var user as.User
187 188 189 190
		var groups []string
		if td.authGroup != "" {
			groups = append(groups, td.authGroup)
		}
191 192 193 194 195
		err := c.request("/api/user/get", &as.GetUserRequest{
			UserRequestBase: as.UserRequestBase{
				RequestBase: as.RequestBase{
					SSO: c.ssoTicket(td.authUser, groups...),
				},
ale's avatar
ale committed
196 197 198 199 200 201 202 203 204 205 206
				Username: "uno@investici.org",
			},
		}, &user)
		if td.expectedOk && err != nil {
			t.Errorf("access error for user %s: expected ok, got error: %v", td.authUser, err)
		} else if !td.expectedOk && err == nil {
			t.Errorf("access error for user %s: expected error, got ok", td.authUser)
		}
	}
}

207 208 209
// Verify that a user can't change someone else's password.
func TestIntegration_ChangeUserPassword_AuthFail(t *testing.T) {
	stop, _, c := startService(t)
ale's avatar
ale committed
210 211
	defer stop()

212 213 214 215
	err := c.request("/api/user/change_password", &as.ChangeUserPasswordRequest{
		PrivilegedRequestBase: as.PrivilegedRequestBase{
			UserRequestBase: as.UserRequestBase{
				RequestBase: as.RequestBase{
216
					SSO: c.ssoTicket("due@investici.org"),
217
				},
ale's avatar
ale committed
218 219
				Username: "uno@investici.org",
			},
220
			CurPassword: "password",
ale's avatar
ale committed
221 222 223 224
		},
		Password: "new_password",
	}, nil)

225 226
	if err == nil {
		t.Fatal("ChangePassword for another user succeeded")
ale's avatar
ale committed
227 228
	}
}
229

230 231
// Verify various attempts at changing the password (user has no encryption keys).
func TestIntegration_ChangeUserPassword(t *testing.T) {
232 233 234 235 236 237 238 239
	runChangeUserPasswordTest(t, "uno@investici.org", as.Config{})
}

// Verify various attempts at changing the password (user has no encryption keys).
func TestIntegration_ChangeUserPassword_WithOpportunisticEncryption(t *testing.T) {
	runChangeUserPasswordTest(t, "uno@investici.org", as.Config{
		EnableOpportunisticEncryption: true,
	})
240 241 242 243
}

// Verify various attempts at changing the password (user with encryption keys).
func TestIntegration_ChangeUserPassword_WithEncryptionKeys(t *testing.T) {
244
	runChangeUserPasswordTest(t, "due@investici.org", as.Config{})
245 246
}

247 248
func runChangeUserPasswordTest(t *testing.T, username string, cfg as.Config) {
	stop, _, c := startServiceWithConfig(t, cfg)
249 250 251 252 253 254 255 256 257 258 259 260
	defer stop()

	testdata := []struct {
		password    string
		newPassword string
		expectedOk  bool
	}{
		// Ordering is important as it is meant to emulate
		// setting the password, failing to reset it, then
		// succeeding.
		{"password", "new_password", true},
		{"BADPASS", "new_password_2", false},
261
		{"new_password", "password", true},
262 263
	}
	for _, td := range testdata {
264 265 266 267
		err := c.request("/api/user/change_password", &as.ChangeUserPasswordRequest{
			PrivilegedRequestBase: as.PrivilegedRequestBase{
				UserRequestBase: as.UserRequestBase{
					RequestBase: as.RequestBase{
268
						SSO: c.ssoTicket(username),
269
					},
270
					Username: username,
271 272 273 274 275 276 277 278 279 280 281 282
				},
				CurPassword: td.password,
			},
			Password: td.newPassword,
		}, nil)
		if err == nil && !td.expectedOk {
			t.Fatalf("ChangeUserPassword(old=%s new=%s) should have failed but didn't", td.password, td.newPassword)
		} else if err != nil && td.expectedOk {
			t.Fatalf("ChangeUserPassword(old=%s new=%s) failed: %v", td.password, td.newPassword, err)
		}
	}
}
ale's avatar
ale committed
283 284

func TestIntegration_CreateResource(t *testing.T) {
285
	stop, _, c := startService(t)
ale's avatar
ale committed
286 287
	defer stop()

288
	testdata := []struct {
289
		resource   *as.Resource
290 291 292 293
		expectedOk bool
	}{
		// Create a domain resource.
		{
294 295
			&as.Resource{
				ID:            as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example2.com"),
ale's avatar
ale committed
296
				Name:          "example2.com",
297
				Status:        as.ResourceStatusActive,
ale's avatar
ale committed
298 299
				Shard:         "host2",
				OriginalShard: "host2",
300
				Website: &as.Website{
ale's avatar
ale committed
301
					URL:          "https://example2.com",
302
					DocumentRoot: "/home/users/investici.org/uno/html-example2.com",
ale's avatar
ale committed
303 304 305
					AcceptMail:   true,
				},
			},
306 307 308 309 310
			true,
		},

		// Duplicate of the above request, should fail due to conflict.
		{
311 312
			&as.Resource{
				ID:            as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example2.com"),
313
				Name:          "example2.com",
314
				Status:        as.ResourceStatusActive,
315 316
				Shard:         "host2",
				OriginalShard: "host2",
317
				Website: &as.Website{
318 319 320 321 322 323 324
					URL:          "https://example2.com",
					DocumentRoot: "/home/users/investici.org/uno/html-example2.com",
				},
			},
			false,
		},

ale's avatar
ale committed
325
		// Empty document root will be fixed by templating.
326
		{
327 328
			&as.Resource{
				ID:            as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example3.com"),
329
				Name:          "example3.com",
330
				Status:        as.ResourceStatusActive,
331 332
				Shard:         "host2",
				OriginalShard: "host2",
333
				Website:       &as.Website{},
334
			},
ale's avatar
ale committed
335
			true,
336 337 338 339
		},

		// Malformed resource metadata (name fails validation).
		{
340 341
			&as.Resource{
				ID:            as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example$.com"),
342
				Name:          "example$.com",
343
				Status:        as.ResourceStatusActive,
344 345
				Shard:         "host2",
				OriginalShard: "host2",
346
				Website: &as.Website{
347 348 349 350 351 352 353 354 355
					URL:          "https://example$.com",
					DocumentRoot: "/home/users/investici.org/uno/html-example3.com",
				},
			},
			false,
		},

		// Bad shard.
		{
356 357
			&as.Resource{
				ID:            as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example3.com"),
ale's avatar
ale committed
358
				Name:          "example4.com",
359
				Status:        as.ResourceStatusActive,
360 361
				Shard:         "zebra",
				OriginalShard: "zebra",
362
				Website: &as.Website{
ale's avatar
ale committed
363 364
					URL:          "https://example4.com",
					DocumentRoot: "/home/users/investici.org/uno/html-example4.com",
365 366 367 368 369 370 371
				},
			},
			false,
		},

		// The document root has no associated DAV account.
		{
372 373
			&as.Resource{
				ID:            as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example3.com"),
ale's avatar
ale committed
374
				Name:          "example5.com",
375
				Status:        as.ResourceStatusActive,
376 377
				Shard:         "host2",
				OriginalShard: "host2",
378
				Website: &as.Website{
ale's avatar
ale committed
379 380
					URL:          "https://example5.com",
					DocumentRoot: "/home/users/investici.org/nonexisting",
381 382 383 384 385 386 387
				},
			},
			false,
		},
	}

	for _, td := range testdata {
388 389 390 391 392 393 394
		err := c.request("/api/resource/create", &as.CreateResourcesRequest{
			AdminRequestBase: as.AdminRequestBase{
				RequestBase: as.RequestBase{
					SSO: c.ssoTicket(testAdminUser),
				},
			},
			Resources: []*as.Resource{td.resource},
395 396 397 398 399 400 401 402 403
		}, nil)
		if err == nil && !td.expectedOk {
			t.Errorf("CreateResource(%s) should have failed but didn't", td.resource.ID)
		} else if err != nil && td.expectedOk {
			t.Errorf("CreateResource(%s) failed: %v", td.resource.ID, err)
		}
	}
}

ale's avatar
ale committed
404
func TestIntegration_CreateMultipleResources_WithTemplate(t *testing.T) {
405
	stop, _, c := startService(t)
406 407
	defer stop()

ale's avatar
ale committed
408 409
	// The create request is very bare, most values will be filled
	// in by the server using resource templates.
410 411 412 413 414 415 416 417 418
	err := c.request("/api/resource/create", &as.CreateResourcesRequest{
		AdminRequestBase: as.AdminRequestBase{
			RequestBase: as.RequestBase{
				SSO: c.ssoTicket(testAdminUser),
			},
		},
		Resources: []*as.Resource{
			&as.Resource{
				ID:   as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example3.com"),
ale's avatar
ale committed
419
				Name: "example3.com",
420
			},
421 422
			&as.Resource{
				ID:   as.NewResourceID(as.ResourceTypeDAV, "uno@investici.org", "example3dav"),
ale's avatar
ale committed
423 424
				Name: "example3dav",
			},
425 426 427
			&as.Resource{
				ID:       as.NewResourceID(as.ResourceTypeDatabase, "uno@investici.org", "cn=example3.com", "example3"),
				ParentID: as.NewResourceID(as.ResourceTypeDomain, "uno@investici.org", "example3.com"),
ale's avatar
ale committed
428
				Name:     "example3",
429
			},
ale's avatar
ale committed
430 431 432
		},
	}, nil)
	if err != nil {
433
		t.Errorf("CreateResources failed: %v", err)
ale's avatar
ale committed
434 435
	}
}
436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562

func TestIntegration_CreateUser(t *testing.T) {
	stop, be, c := startService(t)
	defer stop()

	// The create request is very bare, most values will be filled
	// in by the server using resource templates.
	var resp as.CreateUserResponse
	err := c.request("/api/user/create", &as.CreateUserRequest{
		AdminRequestBase: as.AdminRequestBase{
			RequestBase: as.RequestBase{
				SSO: c.ssoTicket(testAdminUser),
			},
		},
		User: &as.User{
			Name: "newuser@example.com",
			Resources: []*as.Resource{
				&as.Resource{
					ID:   as.NewResourceID(as.ResourceTypeEmail, "newuser@example.com", "newuser@example.com"),
					Name: "newuser@example.com",
				},
			},
		},
	}, &resp)
	if err != nil {
		t.Fatalf("CreateUser failed: %v", err)
	}

	if resp.Password == "" {
		t.Fatalf("no password in response (%v)", resp)
	}

	// Verify that the new password works.
	checkUserInvariants(t, be, "newuser@example.com", resp.Password)
}

func TestIntegration_AccountRecovery(t *testing.T) {
	runAccountRecoveryTest(t, "uno@investici.org")
}

func TestIntegration_AccountRecovery_WithEncryptionKeys(t *testing.T) {
	user := runAccountRecoveryTest(t, "due@investici.org")
	if !user.HasEncryptionKeys {
		t.Fatalf("encryption keys not enabled after account recovery")
	}
}

func runAccountRecoveryTest(t *testing.T, username string) *as.RawUser {
	stop, be, c := startService(t)
	defer stop()

	hint := "secret code?"
	secondaryPw := "open sesame!"
	err := c.request("/api/user/set_account_recovery_hint", &as.SetAccountRecoveryHintRequest{
		PrivilegedRequestBase: as.PrivilegedRequestBase{
			UserRequestBase: as.UserRequestBase{
				RequestBase: as.RequestBase{
					SSO: c.ssoTicket(username),
				},
				Username: username,
			},
			CurPassword: "password",
		},
		Hint:     hint,
		Response: secondaryPw,
	}, nil)
	if err != nil {
		t.Fatalf("SetAccountRecoveryHint failed: %v", err)
	}

	// The first request just fetches the recovery hint.
	var resp as.AccountRecoveryResponse
	err = c.request("/api/recover_account", &as.AccountRecoveryRequest{
		Username: username,
	}, &resp)
	if err != nil {
		t.Fatalf("AccountRecovery (hint only) failed: %v", err)
	}
	if resp.Hint != hint {
		t.Fatalf("bad AccountRecovery hint, got '%s' expected '%s'", resp.Hint, hint)
	}

	// Now recover the account and set a new password.
	newPw := "new password"
	err = c.request("/api/recover_account", &as.AccountRecoveryRequest{
		Username:         username,
		RecoveryPassword: secondaryPw,
		Password:         newPw,
	}, &resp)
	if err != nil {
		t.Fatalf("AccountRecovery failed: %v", err)
	}

	return checkUserInvariants(t, be, username, newPw)
}

// Verify that some user authentication invariants are true. Returns
// the RawUser for further checks.
func checkUserInvariants(t *testing.T, be as.Backend, username, primaryPassword string) *as.RawUser {
	tx, _ := be.NewTransaction()
	user, err := tx.GetUser(context.Background(), username)
	if err != nil {
		t.Fatalf("GetUser(%s): %v", username, err)
	}

	// Verify that the password is correct.
	if !pwhash.ComparePassword(user.Password, primaryPassword) {
		t.Fatalf("password for user %s is not %s", username, primaryPassword)
	}

	// Verify that we can successfully encrypt keys.
	if user.HasEncryptionKeys {
		if _, err := userenckey.Decrypt(keysToBytes(user.Keys), []byte(primaryPassword)); err != nil {
			t.Fatalf("password for user %s can't decrypt keys", username)
		}
	}

	return user
}

func keysToBytes(keys []*as.UserEncryptionKey) [][]byte {
	var rawKeys [][]byte
	for _, k := range keys {
		rawKeys = append(rawKeys, k.Key)
	}
	return rawKeys
}