forgejo/models/unittest/fault_injector.go
Gusted be274b43a6 chore: add SQL fault injector testing (#9314)
TL;DR we can test if transactions are actually working.

Forgejo has many helper functions to test various aspects of the database state, however one aspect it is not able to test is transactions. As this would require that some random SQL query fails to indeed observe that the whole transaction is being rollbacked.

So how do we make a random SQL query fail? Via a fault injector hook, which is always added to xorm (during unittest) and can be enabled on demand to say after how many SQL queries it should start returning a error (fault injecting).

This allows a test to do the following: after two SQL queries lets call it a day and then execute the function that starts a transaction and does a few SQL query. It can then observe that indeed the function was fault injected (`ErrFaultInjected` is returned) and after querying the database it can observe that nothing was changed and thus can conclude the transaction was rollbacked.

---

To demonstrate how the fault injector test helper can be used, lets add a test to a function I really wanted to test but couldn't because the fault injector didn't exist. `NewTwoFactor` was recently made into a transaction (a8c61532d2) and although it would not be catastrophic it would be really bad if records were being inserted if for some reason setting the secret failed.

The test that's added demonstrates that the function uses a transaction and rollbacks correctly.

Weirdly enough the fault injector can be viewed as testing a specification, because it assumes nothing about how the function does it (and you could even design a function that purposely doesn't work but succeeds this test), it merely assumes there's a transaction and within that transaction some SQL queries will be done. However it also needs a certain amount of knowledge about how the function is implemented because the developer needs to tell after how many SQL queries you want to inject a fault and you want to do at a point where there's already a observable change happening in the transaction and not fault inject if the transaction only contains `SELECT` queries.

I'm sure you could design a smart fault injector that can do such guess work (although it sounds like a topic for a PhD thesis) and you could design a helper function that can then guide the fault injector to find every interesting place to do a fault injection and ensure the transaction always falls back; as a first prototype having the programmer tell after how many SQL queries a fault should be injected is sufficient for a lot of the transaction we are going to test in Forgejo.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9314
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
2025-09-18 00:39:06 +02:00

50 lines
1.1 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package unittest
import (
"context"
"errors"
"xorm.io/xorm/contexts"
)
var (
faultInjectorCount int64
faultInjectorNumQueries int64 = -1
ErrFaultInjected = errors.New("nobody expects a fault injection")
)
type faultInjectorHook struct{}
var _ contexts.Hook = &faultInjectorHook{}
func (faultInjectorHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
if faultInjectorNumQueries == -1 {
return c.Ctx, nil
}
// Always allow ROLLBACK, we always want to allow for transactions to get cancelled.
if faultInjectorCount == faultInjectorNumQueries && c.SQL != "ROLLBACK" {
return c.Ctx, ErrFaultInjected
}
faultInjectorCount++
return c.Ctx, nil
}
func (faultInjectorHook) AfterProcess(*contexts.ContextHook) error {
return nil
}
// Allow `numQueries` before all database queries will fail until the
// returning function is executed.
func SetFaultInjector(numQueries int64) func() {
faultInjectorNumQueries = numQueries
return func() {
faultInjectorNumQueries = -1
faultInjectorCount = 0
}
}