package store import ( "bytes" "context" "errors" "fmt" "testing" "time" ) func mkKey(t *testing.T, db *DB, id string, scope Scope, projectID *int64) *APIKey { t.Helper() hash := bytes.Repeat([]byte{byte(len(id))}, 32) k := &APIKey{ID: id, SecretHash: hash, Scope: scope, ProjectID: projectID, Name: "test " + id} if err := db.CreateKey(context.Background(), k); err != nil { t.Fatalf("CreateKey(%s): %v", id, err) } return k } func TestCreateAndReadKey(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } admin := mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil) proj := mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID) got, err := db.KeyByID(ctx, admin.ID) if err != nil { t.Fatalf("KeyByID: %v", err) } if got.Scope != ScopeAdmin { t.Errorf("scope = %q, want admin", got.Scope) } if got.ProjectID != nil { t.Errorf("admin key has project_id %v", *got.ProjectID) } if !bytes.Equal(got.SecretHash, admin.SecretHash) { t.Error("secret hash did not round trip") } if got.CreatedAt.IsZero() { t.Error("created_at not set") } if got.RevokedAt != nil || got.ExpiresAt != nil || got.LastUsedAt != nil { t.Errorf("optional timestamps should be nil: %+v", got) } got, err = db.KeyByID(ctx, proj.ID) if err != nil { t.Fatal(err) } if got.ProjectID == nil || *got.ProjectID != p.ID { t.Errorf("project key lost its project: %+v", got) } } func TestKeyNotFound(t *testing.T) { db := testDB(t) if _, err := db.KeyByID(context.Background(), "missing000000000"); !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } } func TestCreateKeyDuplicateID(t *testing.T) { ctx := context.Background() db := testDB(t) mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil) err := db.CreateKey(ctx, &APIKey{ID: "adminkeyid000000", SecretHash: make([]byte, 32), Scope: ScopeAdmin}) if !errors.Is(err, ErrExists) { t.Fatalf("got %v, want ErrExists", err) } } func TestRevokeKey(t *testing.T) { ctx := context.Background() db := testDB(t) k := mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil) if err := db.RevokeKey(ctx, k.ID); err != nil { t.Fatalf("RevokeKey: %v", err) } got, err := db.KeyByID(ctx, k.ID) if err != nil { t.Fatal(err) } if got.RevokedAt == nil { t.Fatal("revoked_at not set") } first := *got.RevokedAt if got.Usable(time.Now()) { t.Error("a revoked key must not be usable") } // Revoking again must be a no-op, not a moved timestamp: the first time is // when the key actually stopped working. if err := db.RevokeKey(ctx, k.ID); err != nil { t.Fatalf("second RevokeKey: %v", err) } got, err = db.KeyByID(ctx, k.ID) if err != nil { t.Fatal(err) } if !got.RevokedAt.Equal(first) { t.Errorf("revoked_at moved from %v to %v", first, *got.RevokedAt) } if err := db.RevokeKey(ctx, "missing000000000"); !errors.Is(err, ErrNotFound) { t.Errorf("revoking an unknown key: got %v, want ErrNotFound", err) } } func TestKeyUsable(t *testing.T) { now := time.Unix(1_000_000, 0).UTC() past := now.Add(-time.Hour) future := now.Add(time.Hour) cases := []struct { name string key APIKey wantUse bool }{ {"fresh", APIKey{}, true}, {"revoked", APIKey{RevokedAt: &past}, false}, {"expired", APIKey{ExpiresAt: &past}, false}, {"expires later", APIKey{ExpiresAt: &future}, true}, {"expires exactly now", APIKey{ExpiresAt: &now}, false}, {"revoked and unexpired", APIKey{RevokedAt: &past, ExpiresAt: &future}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tc.key.Usable(now); got != tc.wantUse { t.Errorf("Usable = %v, want %v", got, tc.wantUse) } }) } } func TestListKeys(t *testing.T) { ctx := context.Background() db := testDB(t) a := DefaultProject("alpha") b := DefaultProject("beta") if err := db.CreateProject(ctx, a); err != nil { t.Fatal(err) } if err := db.CreateProject(ctx, b); err != nil { t.Fatal(err) } mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil) mkKey(t, db, "alphakey00000001", ScopeProject, &a.ID) mkKey(t, db, "alphakey00000002", ScopeProject, &a.ID) mkKey(t, db, "betakey000000001", ScopeProject, &b.ID) all, err := db.ListKeys(ctx, nil) if err != nil { t.Fatal(err) } if len(all) != 4 { t.Errorf("ListKeys(nil) returned %d keys, want 4", len(all)) } forA, err := db.ListKeys(ctx, &a.ID) if err != nil { t.Fatal(err) } if len(forA) != 2 { t.Fatalf("ListKeys(alpha) returned %d keys, want 2", len(forA)) } for _, k := range forA { if k.ProjectID == nil || *k.ProjectID != a.ID { t.Errorf("key %s leaked into alpha's list", k.ID) } } // Revoked keys stay listed so an operator can see what was revoked and when. if err := db.RevokeKey(ctx, "alphakey00000001"); err != nil { t.Fatal(err) } forA, err = db.ListKeys(ctx, &a.ID) if err != nil { t.Fatal(err) } if len(forA) != 2 { t.Errorf("after revoke, ListKeys(alpha) returned %d keys, want 2", len(forA)) } } // A zero result here is what makes the server mint a bootstrap token, so the // "usable" definition has to match what the verifier will accept. func TestCountUsableAdminKeys(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } if n, err := db.CountUsableAdminKeys(ctx); err != nil || n != 0 { t.Fatalf("empty database: n=%d err=%v, want 0", n, err) } // A project key is not an admin key. mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID) if n, _ := db.CountUsableAdminKeys(ctx); n != 0 { t.Errorf("project key counted as admin: n=%d", n) } mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil) if n, _ := db.CountUsableAdminKeys(ctx); n != 1 { t.Errorf("n=%d, want 1", n) } // An expired admin key must not keep the server from bootstrapping. expired := time.Now().Add(-time.Hour) if err := db.CreateKey(ctx, &APIKey{ ID: "expiredadmin0000", SecretHash: make([]byte, 32), Scope: ScopeAdmin, ExpiresAt: &expired, }); err != nil { t.Fatal(err) } if n, _ := db.CountUsableAdminKeys(ctx); n != 1 { t.Errorf("expired admin key counted: n=%d", n) } if err := db.RevokeKey(ctx, "adminkeyid000000"); err != nil { t.Fatal(err) } if n, _ := db.CountUsableAdminKeys(ctx); n != 0 { t.Errorf("revoked admin key counted: n=%d", n) } } func TestTouchKeys(t *testing.T) { ctx := context.Background() db := testDB(t) mkKey(t, db, "key0000000000001", ScopeAdmin, nil) mkKey(t, db, "key0000000000002", ScopeAdmin, nil) if err := db.TouchKeys(ctx, nil); err != nil { t.Errorf("empty batch should be a no-op: %v", err) } t1 := time.Unix(1_700_000_000, 0) if err := db.TouchKeys(ctx, map[string]time.Time{ "key0000000000001": t1, "key0000000000002": t1, // A key that vanished between the request and the flush must not fail // the whole batch, or one deleted key would stall the flusher forever. "deletedkey000000": t1, }); err != nil { t.Fatalf("TouchKeys: %v", err) } lastUsed := func(id string) *time.Time { t.Helper() k, err := db.KeyByID(ctx, id) if err != nil { t.Fatal(err) } return k.LastUsedAt } if got := lastUsed("key0000000000001"); got == nil || !got.Equal(t1.UTC()) { t.Errorf("last_used_at = %v, want %v", got, t1.UTC()) } // Batches can arrive out of order once the flusher runs concurrently with a // retry; an older timestamp must not walk the column backwards. older := t1.Add(-time.Hour) if err := db.TouchKeys(ctx, map[string]time.Time{"key0000000000001": older}); err != nil { t.Fatal(err) } if got := lastUsed("key0000000000001"); !got.Equal(t1.UTC()) { t.Errorf("last_used_at moved backwards to %v", got) } newer := t1.Add(time.Hour) if err := db.TouchKeys(ctx, map[string]time.Time{"key0000000000001": newer}); err != nil { t.Fatal(err) } if got := lastUsed("key0000000000001"); !got.Equal(newer.UTC()) { t.Errorf("last_used_at = %v, want %v", got, newer.UTC()) } } // The write pool holds a single connection, so a batch flush must not need more // than one; this would deadlock if TouchKeys opened a nested transaction. func TestTouchKeysLargeBatch(t *testing.T) { ctx := context.Background() db := testDB(t) seen := map[string]time.Time{} now := time.Unix(1_700_000_000, 0) for i := 0; i < 200; i++ { id := fmt.Sprintf("key%013d", i) mkKey(t, db, id, ScopeAdmin, nil) seen[id] = now } if err := db.TouchKeys(ctx, seen); err != nil { t.Fatalf("TouchKeys: %v", err) } var n int if err := db.Reader().QueryRow( `SELECT count(*) FROM api_keys WHERE last_used_at = ?`, now.Unix()).Scan(&n); err != nil { t.Fatal(err) } if n != 200 { t.Errorf("%d keys touched, want 200", n) } } // Keys must die with their project, or a project name could be recreated and // inherit the old owner's credentials. func TestKeysCascadeWithProject(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID) if err := db.DeleteProject(ctx, p.ID); err != nil { t.Fatal(err) } if _, err := db.KeyByID(ctx, "projkeyid0000000"); !errors.Is(err, ErrNotFound) { t.Errorf("key survived its project: %v", err) } }