mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 19:17:43 +00:00
Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8115c3a737 | ||
|
|
c01307202d | ||
|
|
5ecb1e6e4c | ||
|
|
9c299805c1 | ||
|
|
2a900c9876 | ||
|
|
0008b943a3 | ||
|
|
50c74b1f96 | ||
|
|
ae1eef6d4f | ||
|
|
d01cdc040f | ||
|
|
92b9af17b7 | ||
|
|
898e1b023a | ||
|
|
a90c67473e | ||
|
|
1a84b8a892 | ||
|
|
656d14cd54 | ||
|
|
9dd271ecab | ||
|
|
4973ee030d | ||
|
|
137bb3ad33 | ||
|
|
03d587db6a | ||
|
|
1f56b956d2 | ||
|
|
37aece9791 | ||
|
|
309b400bc0 | ||
|
|
1b9868b72b | ||
|
|
25344aa572 | ||
|
|
ea9d27cf6d | ||
|
|
0bee8ec46c | ||
|
|
3a5afeb518 | ||
|
|
fc8ba4d615 | ||
|
|
dc20062e5b | ||
|
|
ff430c509b | ||
|
|
d43d7a46dd | ||
|
|
72c496ab93 | ||
|
|
b228152308 | ||
|
|
006756ea40 | ||
|
|
aa539c061f | ||
|
|
74b0ee8776 | ||
|
|
412413c12f | ||
|
|
35d213f339 | ||
|
|
9c68bc25a5 | ||
|
|
9c5d9d16a7 | ||
|
|
46c10f999a | ||
|
|
d12f40c9a0 | ||
|
|
cd363fce3d | ||
|
|
709cebd4a7 | ||
|
|
ba5ef9f79c | ||
|
|
2c50317a98 | ||
|
|
28350a15bb | ||
|
|
c6d3ea5f81 | ||
|
|
7fadb4b585 | ||
|
|
691df82016 | ||
|
|
ea348fa9d1 | ||
|
|
925c6772a6 | ||
|
|
0c60e44aee | ||
|
|
a716086295 | ||
|
|
f8a5066a40 | ||
|
|
f978967ef1 | ||
|
|
d6e2c02fda | ||
|
|
e98b65cf90 | ||
|
|
bc5411c30c | ||
|
|
27f23121f0 | ||
|
|
047b23872c | ||
|
|
84bd87dcc9 | ||
|
|
0e7a13aeba | ||
|
|
85d50da494 | ||
|
|
1d9def4314 | ||
|
|
ed9bbd01e2 | ||
|
|
523d6a3649 | ||
|
|
6326962862 | ||
|
|
cd7c8375c0 | ||
|
|
63bcc912ef | ||
|
|
adcdd2edcd | ||
|
|
29406f839e | ||
|
|
a43133ab7b | ||
|
|
7002cd4065 | ||
|
|
8faa8d2aed | ||
|
|
705427178d | ||
|
|
8f10d202e6 | ||
|
|
5648bd1dcf | ||
|
|
a442eadb96 | ||
|
|
f3b67e9abc | ||
|
|
4e51f56623 | ||
|
|
7e4e17bbcf | ||
|
|
799e892a68 | ||
|
|
0e2adb3165 | ||
|
|
249e044ded | ||
|
|
d6309c75be | ||
|
|
211ae85a4e | ||
|
|
3c3d94ca76 | ||
|
|
6138d2d74c | ||
|
|
d5de79f75b | ||
|
|
46e793972c | ||
|
|
46f4092b43 | ||
|
|
196195357b | ||
|
|
c579c5f84c | ||
|
|
241c27358b | ||
|
|
aa3c9866cb | ||
|
|
2ac01ea584 | ||
|
|
7f9cc1e435 | ||
|
|
4fb0529d2d | ||
|
|
8ee4141af6 | ||
|
|
36f2549172 | ||
|
|
dff0e64f51 | ||
|
|
0b78bc1e2e | ||
|
|
94163f9afb | ||
|
|
4510b06959 | ||
|
|
750c7c744e | ||
|
|
d52dca1cb6 | ||
|
|
71413a4248 | ||
|
|
4fede43254 | ||
|
|
0a629e2f3f | ||
|
|
37065fb089 | ||
|
|
6966f14dd4 | ||
|
|
22716e90c1 | ||
|
|
73cce7fc2f | ||
|
|
b59c7f0d46 | ||
|
|
d3a44a2a6b | ||
|
|
5c3c3907d5 | ||
|
|
f2215e132e | ||
|
|
a69afab34f | ||
|
|
e0132db1b9 | ||
|
|
7c3f55a873 | ||
|
|
f7c0247394 | ||
|
|
ac23556029 | ||
|
|
f64115e03a | ||
|
|
a2524c31bf | ||
|
|
71d6211c61 | ||
|
|
c67760bc39 | ||
|
|
d3e7f46a46 | ||
|
|
55866682ae | ||
|
|
550e95ff43 | ||
|
|
060b6cfd64 | ||
|
|
89a4738394 | ||
|
|
d8529289cf | ||
|
|
4a8f97b1ee | ||
|
|
d54ac844ef | ||
|
|
379fba515f | ||
|
|
58105cb478 | ||
|
|
47af6f4306 | ||
|
|
7d29c9953a | ||
|
|
0729624c2f | ||
|
|
b3a740ab2a | ||
|
|
adf617f5d0 | ||
|
|
049a20cd04 | ||
|
|
be3c4452e3 | ||
|
|
5b01c9ada8 | ||
|
|
ffd82a6a10 | ||
|
|
dd8d89a990 | ||
|
|
2e5b23565e | ||
|
|
f4e3f04d30 | ||
|
|
954ebdc9eb | ||
|
|
2840010dfd | ||
|
|
b147d9b833 | ||
|
|
b3ecb81614 | ||
|
|
ce4581bb99 | ||
|
|
f406ca0160 | ||
|
|
39ea1f6aef | ||
|
|
b2053f507a | ||
|
|
9520117914 | ||
|
|
7a5fc7d440 | ||
|
|
bd5e83d464 | ||
|
|
63dd40a8d7 | ||
|
|
32bd88504d | ||
|
|
d70818a9db | ||
|
|
9d4a425fc0 | ||
|
|
4d6456a588 | ||
|
|
07ff92aa55 | ||
|
|
4156387eb9 | ||
|
|
34773a0a81 | ||
|
|
36a018400b | ||
|
|
7bb02c5f1d | ||
|
|
15fb128236 | ||
|
|
3581e060ec | ||
|
|
0604a29596 | ||
|
|
d8a2958797 | ||
|
|
ab28fa7bed | ||
|
|
e88d751039 | ||
|
|
b836945eea | ||
|
|
d7a8e66753 | ||
|
|
68780a845c | ||
|
|
487dc94a2e | ||
|
|
016e977776 | ||
|
|
dcfe512204 | ||
|
|
fe6ebfd47c | ||
|
|
eba5fba3da | ||
|
|
b7e9a79225 | ||
|
|
deffb19fd8 | ||
|
|
c858b322bd | ||
|
|
1b5b52f0f1 | ||
|
|
ecb31a158b | ||
|
|
1aecc140dc | ||
|
|
e464a4aedd | ||
|
|
d115c5299c | ||
|
|
9bd542bb59 |
@@ -49,8 +49,18 @@ model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
|
||||
### 4. 写菜单、接口与权限种子数据
|
||||
|
||||
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
|
||||
没权限,往往就是漏了这一步。**完整参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`**
|
||||
——那是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子,逐字照抄结构,只换 ID 和业务字段。
|
||||
没权限,往往就是漏了这一步。结构参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`
|
||||
——它是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子。
|
||||
|
||||
:::danger
|
||||
**但不要照抄它的 import。** 那个文件用的是 `cmd/migrate/migration/models`,
|
||||
只因为它的版本号排在软删除转换(`1786700003000`)之前才是安全的。
|
||||
|
||||
**你新写的迁移版本号在转换之后,必须改用 `app/` 下的运行时模型**
|
||||
(`app/admin/models.SysApi`、`SysMenu`),否则第一条 insert 就会
|
||||
`NOT NULL constraint failed: sys_api.deleted_at`。
|
||||
`TestPostConversionMigrationsAvoidFrozenSeedModels` 会拦住这个错误。
|
||||
:::
|
||||
|
||||
一个模块要在界面上可用,需要四类数据,缺一样都不行:
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
name: Build
|
||||
|
||||
# Documentation-only changes skip this workflow entirely.
|
||||
# Documentation-only changes, and changes confined to the Kubernetes
|
||||
# manifests, skip this workflow entirely.
|
||||
#
|
||||
# A push to master here does not just build - it pushes an image, runs the
|
||||
# migrations and restarts the demo container, so the site takes a short outage.
|
||||
# Paying that for a README edit is waste at best; at worst a deploy fails for a
|
||||
# reason unrelated to anything in the change. Code coverage is unaffected,
|
||||
# because go.yml still builds every push and pull request.
|
||||
#
|
||||
# scripts/k8s holds deploy.yml, storage.yml and prerun.sh, and the deploy below
|
||||
# reads none of them - it is an ssh into one host that runs docker, building the
|
||||
# Dockerfile at the repository root. Those manifests are for people deploying to
|
||||
# a cluster of their own. The pattern is scripts/k8s/** rather than scripts/**
|
||||
# because scripts/Dockerfile is a build input: go.yml builds the release image
|
||||
# from it on a tag.
|
||||
#
|
||||
# A file outside these patterns still runs the workflow even when the rest of
|
||||
# the change is ignorable: paths-ignore skips only when every changed path
|
||||
# matches. Editing this file is one such case, on purpose - a deploy script
|
||||
# that is never exercised by the change that broke it is worse than an outage.
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
@@ -15,6 +28,7 @@ on:
|
||||
- 'docs/**'
|
||||
- 'LICENSE*'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- 'scripts/k8s/**'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
@@ -22,6 +36,7 @@ on:
|
||||
- 'docs/**'
|
||||
- 'LICENSE*'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- 'scripts/k8s/**'
|
||||
|
||||
# One deploy at a time. Two merges seconds apart raced here: both runs did
|
||||
# docker rm -f then docker run, the second removed the container the first had
|
||||
@@ -102,7 +117,41 @@ jobs:
|
||||
|
||||
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
|
||||
|
||||
# Old images of this repository are removed here and nowhere else.
|
||||
# Every deployment pulls one tagged with its commit and nothing ever
|
||||
# removed the previous one, so they only accumulated: 68 of them
|
||||
# filled the disk and the next deployment could not pull.
|
||||
#
|
||||
# Three are kept so a release can be re-run by tag by hand.
|
||||
#
|
||||
# Only this repository's images are listed, because the host runs
|
||||
# other services whose images are not this script's business. The
|
||||
# image the live container is on is excluded by id rather than by
|
||||
# position, so it survives even if the listing order is not what
|
||||
# it looks like. With no container to ask, the function returns
|
||||
# rather than running the pipeline on an empty id - which would
|
||||
# also delete nothing, but by way of grep -v matching every line,
|
||||
# which reads like the opposite of what it does. No -f, so an image
|
||||
# any container still holds - including the one kept for rollback -
|
||||
# is refused rather than taken away from it.
|
||||
prune_old_images() {
|
||||
REPO="${IMG%:*}"
|
||||
LIVE=$(sudo docker inspect -f '{{.Image}}' "$NAME" 2>/dev/null | sed 's/^sha256://' | cut -c1-12)
|
||||
[ -n "$LIVE" ] || return 0
|
||||
sudo docker images "$REPO" --format '{{.ID}} {{.Repository}}:{{.Tag}}' \
|
||||
| grep -v "^$LIVE" \
|
||||
| tail -n +3 \
|
||||
| awk '{print $2}' \
|
||||
| xargs -r -n1 sudo docker rmi >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
# Before the pull, not only after a successful deploy. The pull
|
||||
# is the first thing here that needs space and it is where a full
|
||||
# disk stops this script, so a cleanup that only runs afterwards
|
||||
# never runs on the host that needs it: rerunning the workflow
|
||||
# fails at the same pull, and the disk has to be cleared by hand.
|
||||
prune_old_images
|
||||
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
|
||||
|
||||
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
|
||||
@@ -115,7 +164,14 @@ jobs:
|
||||
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
|
||||
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
|
||||
sudo docker rename "$NAME" "$PREV"
|
||||
sudo docker stop "$PREV" >/dev/null
|
||||
# --timeout, because the default is 10 seconds and the process
|
||||
# spends drain + server + cleanup from extend.shutdown before it
|
||||
# exits - 8 seconds out of the box, and more for anyone who
|
||||
# configures a drain window. Past the deadline docker sends
|
||||
# SIGKILL and the cleanup callbacks are cut off part-way through.
|
||||
# checksilent's docker-stop-cuts-shutdown-short check compares
|
||||
# this number against config/settings.yml.
|
||||
sudo docker stop --timeout 30 "$PREV" >/dev/null
|
||||
fi
|
||||
|
||||
sudo docker run -d -p 8000:8000 \
|
||||
@@ -133,6 +189,10 @@ jobs:
|
||||
|
||||
if [ "$ok" = "1" ]; then
|
||||
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
|
||||
|
||||
# Again, so the image this deployment replaced falls out of the
|
||||
# window rather than waiting for the next deployment to notice.
|
||||
prune_old_images
|
||||
else
|
||||
echo "健康检查失败,回滚到上一版本"
|
||||
sudo docker logs --tail 40 "$NAME" 2>&1 || true
|
||||
|
||||
@@ -15,6 +15,69 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# The queue's ordering rule - consumers registered before the queue is
|
||||
# started - is invisible on the memory backend, which is the default and
|
||||
# therefore what every other test runs on: queue.Memory's Register starts a
|
||||
# consumer goroutine whatever the state. Only redis refuses a late
|
||||
# registration, so without a server here the tests that cover it would skip
|
||||
# and the suite would report success for a queue that accepts no consumers.
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: goadmin_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
# The fourth registered driver, and the one that disagrees with the
|
||||
# other three about NULL: its unique index treats two NULLs as equal and
|
||||
# permits one. A migration that builds a unique index over a nullable
|
||||
# column therefore fails here and nowhere else, which is how one shipped
|
||||
# that no SQL Server database could apply at all - not even an empty
|
||||
# one. The password is this container's only credential and the
|
||||
# container lives for the length of one job.
|
||||
sqlserver:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
env:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_SA_PASSWORD: GoAdmin_Test1
|
||||
MSSQL_PID: Developer
|
||||
ports:
|
||||
- 1433:1433
|
||||
options: >-
|
||||
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P GoAdmin_Test1 -C -Q 'SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
--health-start-period 20s
|
||||
|
||||
env:
|
||||
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
|
||||
# The soft-delete conversion drops an index, and gorm's PostgreSQL driver
|
||||
# produced unparseable SQL for that - on SQLite, where the rest of these
|
||||
# tests run, the same code works. The suite reported success for a
|
||||
# migration that failed on every PostgreSQL database it was pointed at.
|
||||
# See go-admin#919.
|
||||
GO_ADMIN_TEST_POSTGRES_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=goadmin_test sslmode=disable"
|
||||
GO_ADMIN_TEST_SQLSERVER_DSN: "sqlserver://sa:GoAdmin_Test1@127.0.0.1:1433?database=goadmin_test"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.26
|
||||
@@ -26,11 +89,42 @@ jobs:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# SQL Server has no equivalent of POSTGRES_DB, so the database the DSN
|
||||
# names has to be created before the tests run.
|
||||
- name: Create the SQL Server test database
|
||||
run: |
|
||||
docker exec ${{ job.services.sqlserver.id }} /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P GoAdmin_Test1 -C \
|
||||
-Q "IF DB_ID('goadmin_test') IS NULL CREATE DATABASE goadmin_test"
|
||||
|
||||
- name: Get dependencies
|
||||
run: go mod tidy
|
||||
|
||||
# go build does not compile _test.go, so building alone never ran a single
|
||||
# test. This is the only workflow that fires on every push and pull request,
|
||||
# which makes it the one place a test gate belongs.
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# A separate module, so none of the steps above see it: the main module's
|
||||
# go.mod, its build and its tests are all unaware of the example
|
||||
# application. This is the only thing that exercises an application being
|
||||
# installed at all - everything below it runs against an injected engine
|
||||
# and a hand-built schema, and none of that can catch an application's
|
||||
# init() reaching one registry and not the other.
|
||||
- name: End-to-end install and uninstall
|
||||
run: make test-e2e
|
||||
|
||||
# Fails the build on the silent-failure classes listed in
|
||||
# tools/checksilent, one of which is the contract boundary: nothing under
|
||||
# common/ may import app/. A boundary that is only written down erodes; this
|
||||
# is what keeps it true.
|
||||
- name: Silent-failure checks
|
||||
run: make checksilent
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
+11
@@ -6,6 +6,10 @@ main.exe
|
||||
*.exe
|
||||
go-admin
|
||||
go-admin.exe
|
||||
# `go build ./tools/checksilent` drops the binary here, next to the one for the
|
||||
# server. Anchored with a leading slash: unanchored, the same pattern matches
|
||||
# tools/checksilent/ as well and the tool's own source never gets committed.
|
||||
/checksilent
|
||||
temp/
|
||||
!temp
|
||||
vendor
|
||||
@@ -29,3 +33,10 @@ CLAUDE.md
|
||||
.claude/skills/*
|
||||
!.claude/skills/new-business-module/
|
||||
config/settings.local.dev.yml
|
||||
|
||||
# Go workspace files. They exist to point this module at a local checkout of
|
||||
# go-admin-core while the two are developed together, which is a private
|
||||
# arrangement between one machine's directories - committing one would break
|
||||
# the build for everyone else.
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
@@ -20,7 +20,7 @@ Router → Api → Service → Model
|
||||
|
||||
## 优先使用通用 Action
|
||||
|
||||
单表 CRUD **不要手写 Handler 与 Service**。`common/actions` 提供的五个
|
||||
单表 CRUD **不要手写 Api 与 Service**。`common/actions` 提供的五个
|
||||
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
|
||||
|
||||
```go
|
||||
@@ -49,7 +49,7 @@ r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middlewa
|
||||
就地返回会串数据(`app/demo` 的测试锁定了这一点)
|
||||
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
|
||||
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Api
|
||||
与 Service,写法见下。
|
||||
|
||||
## Api 层(仅在通用 Action 不适用时)
|
||||
@@ -117,6 +117,21 @@ func (SysPost) TableName() string { return "sys_post" }
|
||||
|
||||
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
|
||||
|
||||
## 公共契约面
|
||||
|
||||
第三方应用(`app/` 下的业务模块)可以稳定依赖哪些包、路由与迁移怎么注册、
|
||||
哪些约束是硬的,见 `docs/contract.md`。
|
||||
|
||||
两条与主仓贡献者直接相关的:
|
||||
|
||||
- **`common/`、`core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
|
||||
- **从 core 契约包声明出来的类型必须写成别名**(`type X = pkg.Y`,不是 `type X pkg.Y`)
|
||||
—— `contract-shim-alias` 检查守着。defined type 会丢掉整个方法集,
|
||||
而且**不一定在本仓编译失败**,理由见 `docs/contract.md` 末节。
|
||||
- **注册类 API(`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`)
|
||||
必须在 `runStartupHooks()` 之前调用完** —— `init()` 是最省事的位置,
|
||||
但约束的是**顺序**,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
|
||||
|
||||
## 路由注册
|
||||
|
||||
通过 `init()` 自注册,不在中心文件手工添加:
|
||||
@@ -152,7 +167,7 @@ sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂
|
||||
|
||||
## Swagger
|
||||
|
||||
Handler 必须带完整注解,`go generate` 会据此生成文档:
|
||||
Api 必须带完整注解,`go generate` 会据此生成文档:
|
||||
|
||||
```go
|
||||
// @Summary 岗位列表
|
||||
@@ -179,7 +194,8 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
|
||||
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
|
||||
**已执行过的迁移文件不可修改** ——
|
||||
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
|
||||
|
||||
放哪个目录取决于身份:
|
||||
@@ -193,6 +209,54 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
|
||||
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
|
||||
|
||||
### 写种子数据用哪个 models 包
|
||||
|
||||
`1786700003000` 之后新增的迁移,**种子数据要用 `app/` 下的运行时模型**
|
||||
(如 `app/admin/models.SysApi`、`SysMenu`),**不要用 `cmd/migrate/migration/models`**。
|
||||
|
||||
后者的 `ModelTime` 声明的是可空的 `gorm.DeletedAt`,这对它之前的迁移是对的(那正是
|
||||
当时列的形状),转换之后就不再成立,两个方向都会出问题:
|
||||
|
||||
- **写**:往 NOT NULL 列里塞 NULL,第一条 insert 就 `NOT NULL constraint failed`
|
||||
- **读**:GORM 拼 `WHERE deleted_at IS NULL`,而活跃行存的是 `0`,静默查不到——
|
||||
照抄 `demo_menu.go` 的授权段落会因此跳过授权,菜单建好、权限没授、迁移仍记为成功
|
||||
|
||||
干净库跑不出这个问题,今天所有用该包的迁移都排在转换之前。完整推导见
|
||||
`schema_coverage_test.go` 里 `TestPostConversionMigrationsAvoidFrozenSeedModels`
|
||||
的注释,那个测试也守着这条边界。
|
||||
|
||||
## 静默失败校验
|
||||
|
||||
`make checksilent` 逐条检查那些**不报错、不记日志、行为悄悄变得不对**的问题,
|
||||
CI 会跑,命中 ERROR 即失败。这里不写条数——写死的数字会悄悄过时,
|
||||
真正的清单是 `tools/checksilent/checks.go` 里 `runChecks` 跑的那几个:
|
||||
|
||||
| 检查 | 级别 | 静默后果 |
|
||||
|---|---|---|
|
||||
| `modeltime-mix` | ERROR | 两个 `ModelTime` 混用,整张表查不到数据 |
|
||||
| `menu-sort-overflow` | ERROR | 菜单 `sort` 超 127,MySQL tinyint 拒绝写入,迁移中断 |
|
||||
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
|
||||
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
|
||||
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
|
||||
| `contract-shim-alias` | ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
|
||||
| `datascope-route-unguarded` | ERROR | handler 读调用方的数据权限,而注册它的路由组没装提供权限的中间件。取不到时拿到零值、走 fail-closed 分支,查询被塞进 `1 = 0`:接口对确实存在的行返回「查不到」,且只在 `enabledp: true` 的部署上出现 |
|
||||
| `shutdown-budget-overruns-grace` | ERROR / WARN | `settings.yml` 的 `extend.shutdown` 预算(含清单里的 `preStop`)放不进自带 k8s 清单的 `terminationGracePeriodSeconds`,SIGKILL 在清理回调跑到一半时到达 |
|
||||
| `docker-stop-cuts-shutdown-short` | ERROR / WARN | 停止容器的两条路径——脚本/工作流里的 `docker stop`,和 `docker-compose.yml` 的 `stop_grace_period`——没写或写得不够关闭预算用。两边默认都是 10 秒,而这个数字离命令很远,调大预算的人不会想起它 |
|
||||
| `menu-name-mismatch` | WARN | 菜单名与前端组件 `name` 不一致,keep-alive 缓存静默失效 |
|
||||
|
||||
两条关闭预算检查分两级,用的是同一条算术和同一个 5 秒边际:真的超限报 ERROR,
|
||||
放得进但余量不足 5 秒报 WARN。余量不足做 WARN 不做 ERROR,是因为那是个技术上
|
||||
跑得通的配置——**一条在正确配置下也会响的 ERROR,训练的是忽略它**。
|
||||
|
||||
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,**不影响退出码**,
|
||||
且默认跳过;要跑它得指定前端目录:
|
||||
|
||||
```bash
|
||||
make checksilent UI_DIR=../go-admin-ui/src
|
||||
```
|
||||
|
||||
升级门槛:连续 2 个发版周期零误报后转为 ERROR。
|
||||
|
||||
## 提交规范
|
||||
|
||||
格式 `type+emoji: 描述`:
|
||||
|
||||
+21
-5
@@ -4,10 +4,26 @@ FROM alpine
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
|
||||
|
||||
RUN apk update --no-cache
|
||||
RUN apk add --update gcc g++ libc6-compat
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache tzdata
|
||||
# Runtime packages only.
|
||||
#
|
||||
# gcc and g++ used to be installed here and were 273MB of a 381MB image. The
|
||||
# binary this image runs is compiled and linked before the image is built and
|
||||
# arrives as a COPY, so nothing in the container ever invokes a compiler -
|
||||
# there is no toolchain to drive it with either, since Go itself is not
|
||||
# installed.
|
||||
#
|
||||
# That layer was also why a host could not share storage between images. apk
|
||||
# resolves against an index that moves, so the layer digest differed on every
|
||||
# build and no two images shared it: a host keeping one image per deployed
|
||||
# commit stored a private 273MB copy each time. 68 of them filled the disk
|
||||
# and the next deployment could not pull.
|
||||
#
|
||||
# libc6-compat stays. Nothing measured needs it - the binary CI produces is
|
||||
# statically linked, and a container built without libc6-compat resolves a
|
||||
# hostname and opens a database connection exactly as one built with it - but
|
||||
# it is half a megabyte and it covers a ./main that was linked dynamically,
|
||||
# which this Dockerfile has no way to check.
|
||||
RUN apk add --no-cache ca-certificates tzdata libc6-compat
|
||||
ENV TZ Asia/Shanghai
|
||||
|
||||
COPY ./main /main
|
||||
@@ -15,4 +31,4 @@ COPY ./config/settings.demo.yml /config/settings.yml
|
||||
COPY ./go-admin-db.db /go-admin-db.db
|
||||
EXPOSE 8000
|
||||
RUN chmod +x /main
|
||||
CMD ["/main","server","-c", "/config/settings.yml"]
|
||||
CMD ["/main","server","-c", "/config/settings.yml"]
|
||||
|
||||
@@ -15,7 +15,16 @@ build-sqlite:
|
||||
# make run
|
||||
run:
|
||||
# delete go-admin-api container
|
||||
@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker rm -f go-admin; fi
|
||||
#
|
||||
# stop then rm, rather than `rm -f`. The force flag kills a running
|
||||
# container with SIGKILL and no grace at all, so restarting locally cut
|
||||
# short every shutdown this application does - the drain window was never
|
||||
# once reached on a developer's machine. --timeout has to cover
|
||||
# extend.shutdown's drain + server + cleanup; checksilent's
|
||||
# docker-stop-cuts-shutdown-short check compares it against
|
||||
# config/settings.yml. On a container that has already stopped, stop is a
|
||||
# no-op and the removal is unchanged.
|
||||
@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker stop --timeout 30 go-admin && docker rm go-admin; fi
|
||||
|
||||
# 启动方法一 run go-admin-api container docker-compose 启动方式
|
||||
# 进入到项目根目录 执行 make run 命令
|
||||
@@ -37,9 +46,39 @@ stop:
|
||||
#@echo "go-admin stop success"
|
||||
|
||||
|
||||
#.PHONY: test
|
||||
#test:
|
||||
# go test -v ./... -cover
|
||||
# -race is worth the extra minute here: common/actions reuses model instances
|
||||
# across concurrent requests, so a Generate() that returns in place instead of
|
||||
# a copy leaks data between them - and that is invisible to a single-threaded
|
||||
# test run.
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -race -cover ./...
|
||||
|
||||
# The end-to-end install, which `test` above cannot reach: test/e2e-apporder
|
||||
# is its own module, so `./...` in this one does not include it. It builds a
|
||||
# go-admin binary with the example application linked in and drives
|
||||
# `migrate install` / `migrate uninstall` against a real database.
|
||||
#
|
||||
# Its own target rather than a line in the CI workflow, so the one thing in
|
||||
# the build that exercises installing an application is also the one thing
|
||||
# somebody can run before pushing.
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
cd test/e2e-apporder && go test ./... -count=1
|
||||
|
||||
# Reports the failures that do not announce themselves - see
|
||||
# tools/checksilent. Exits non-zero on an ERROR; the one WARN-level check
|
||||
# prints and does not fail the build.
|
||||
#
|
||||
# Pass UI_DIR to enable the cross-repository menu-name check, which is skipped
|
||||
# without it: make checksilent UI_DIR=../go-admin-ui/src
|
||||
.PHONY: checksilent
|
||||
checksilent:
|
||||
ifdef UI_DIR
|
||||
go run ./tools/checksilent -ui-dir $(UI_DIR)
|
||||
else
|
||||
go run ./tools/checksilent
|
||||
endif
|
||||
|
||||
#.PHONY: docker
|
||||
#docker:
|
||||
|
||||
@@ -277,15 +277,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -299,8 +295,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
@@ -277,15 +277,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -299,8 +295,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
@@ -263,15 +263,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -285,8 +281,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
@@ -277,15 +277,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -299,8 +295,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
@@ -3,9 +3,9 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
@@ -145,4 +145,4 @@ func (e SysApi) DeleteSysApi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
@@ -216,5 +216,5 @@ func (e SysDictData) GetAll(c *gin.Context) {
|
||||
l = append(l, d)
|
||||
}
|
||||
|
||||
e.OK(l,"查询成功")
|
||||
e.OK(l, "查询成功")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
@@ -31,7 +31,7 @@ type SysDictType struct {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetPage(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
req := dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -62,7 +62,7 @@ func (e SysDictType) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Get(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetReq{}
|
||||
req := dto.SysDictTypeGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -82,7 +82,7 @@ func (e SysDictType) Get(c *gin.Context) {
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
//Insert 字典类型创建
|
||||
// Insert 字典类型创建
|
||||
// @Summary 添加字典类型
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
@@ -94,7 +94,7 @@ func (e SysDictType) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Insert(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeInsertReq{}
|
||||
req := dto.SysDictTypeInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -109,7 +109,7 @@ func (e SysDictType) Insert(c *gin.Context) {
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err,fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
@@ -127,7 +127,7 @@ func (e SysDictType) Insert(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Update(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeUpdateReq{}
|
||||
req := dto.SysDictTypeUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -157,7 +157,7 @@ func (e SysDictType) Update(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Delete(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeDeleteReq{}
|
||||
req := dto.SysDictTypeDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -189,7 +189,7 @@ func (e SysDictType) Delete(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetAll(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
req := dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -207,4 +207,4 @@ func (e SysDictType) GetAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(list, "查询成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type SysLoginLog struct {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetPageReq{}
|
||||
req := dto.SysLoginLogGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -60,7 +60,7 @@ func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Get(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetReq{}
|
||||
req := dto.SysLoginLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
@@ -90,7 +90,7 @@ func (e SysLoginLog) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogDeleteReq{}
|
||||
req := dto.SysLoginLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -107,4 +107,4 @@ func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (e SysOperaLog) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Get(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogGetReq{}
|
||||
req := dto.SysOperaLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -96,7 +96,7 @@ func (e SysOperaLog) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogDeleteReq{}
|
||||
req := dto.SysOperaLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -111,7 +111,7 @@ func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500,err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
|
||||
@@ -2,12 +2,12 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
@@ -31,7 +31,7 @@ type SysPost struct {
|
||||
// @Security Bearer
|
||||
func (e SysPost) GetPage(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostPageReq{}
|
||||
req := dto.SysPostPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -65,7 +65,7 @@ func (e SysPost) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Get(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostGetReq{}
|
||||
req := dto.SysPostGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -99,7 +99,7 @@ func (e SysPost) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Insert(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostInsertReq{}
|
||||
req := dto.SysPostInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -131,7 +131,7 @@ func (e SysPost) Insert(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Update(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostUpdateReq{}
|
||||
req := dto.SysPostUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -163,7 +163,7 @@ func (e SysPost) Update(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Delete(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostDeleteReq{}
|
||||
req := dto.SysPostDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -181,4 +181,4 @@ func (e SysPost) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"go-admin/app/admin/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
type SysUser struct {
|
||||
@@ -149,12 +151,34 @@ func (e SysUser) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
callerId := user.GetUserId(c)
|
||||
|
||||
// This route is in CasbinExclude so the personal-center screen can edit
|
||||
// the caller's own record without a policy grant (see settings.go). That
|
||||
// exclusion covers the whole route, not just the caller's own record, and
|
||||
// the request carries the target userId in the body - so without this
|
||||
// check here, any authenticated caller could edit any other user, up to
|
||||
// and including their roleId. When the target is someone else, ask Casbin
|
||||
// directly for the permission AuthCheckRole skipped.
|
||||
if req.UserId != callerId {
|
||||
allowed, err := middleware.EnforceRoleFor(c, c.Request.URL.Path, c.Request.Method)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
e.Error(http.StatusForbidden, errors.New("无权更新其他用户数据"), "对不起,您没有该接口访问权限,请联系管理员")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
req.SetUpdateBy(callerId)
|
||||
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
err = s.Update(&req, p)
|
||||
err = s.Update(&req, p, callerId)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
@@ -420,7 +444,6 @@ func (e SysUser) GetInfo(c *gin.Context) {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var roles = make([]string, 1)
|
||||
roles[0] = user.GetRoleName(c)
|
||||
var permissions = make([]string, 1)
|
||||
@@ -440,7 +463,14 @@ func (e SysUser) GetInfo(c *gin.Context) {
|
||||
}
|
||||
sysUser := models.SysUser{}
|
||||
req.Id = user.GetUserId(c)
|
||||
err = s.Get(&req, p, &sysUser)
|
||||
// Unscoped on purpose: the id is the caller's own, taken from the token.
|
||||
// This used to go through Get with whatever GetPermissionFromContext
|
||||
// returned - and this route installs no PermissionAction, so that was the
|
||||
// zero value. An unset scope is not a recognised one, so once unknown
|
||||
// scopes started failing closed rather than silently matching everything,
|
||||
// every login on a deployment with enabledp: true ended here with a 401
|
||||
// and the browser went straight back to the login page.
|
||||
err = s.GetSelf(&req, &sysUser)
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnauthorized, err, "登录失败")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// PUT /api/v1/sys-user is in settings.go's CasbinExclude so the
|
||||
// personal-center screen (go-admin-ui's userInfo.vue) can edit the caller's
|
||||
// own record without holding a policy grant on this route. AuthCheckRole
|
||||
// skips Enforce entirely for an excluded route, so this file's job is to pin
|
||||
// what the handler itself now has to hold shut: the target userId comes from
|
||||
// the request body, and nothing upstream of the handler ever checked it
|
||||
// against the caller.
|
||||
|
||||
// setupPrivescDB wires an in-memory database and a Casbin enforcer with an
|
||||
// empty policy - the state of a fresh install for any role but admin - under
|
||||
// a tenant unique to the calling test, so mycasbin's process-wide enforcer
|
||||
// cache can't hand one test's database to another.
|
||||
func setupPrivescDB(t *testing.T) (*gorm.DB, string) {
|
||||
t.Helper()
|
||||
|
||||
// Fatalf, not Skipf: this database is in-memory sqlite with no external
|
||||
// dependency, so failing to open or migrate it means the environment is
|
||||
// actually broken. Skipping here would let these two anti-privesc
|
||||
// regression tests silently stop running while CI stays green - a
|
||||
// standing assertion that never fires is worse than no assertion.
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite unavailable: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysUser{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
tenant := "sys-user-privesc-" + t.Name()
|
||||
|
||||
previousInterval := mycasbin.ReloadInterval
|
||||
mycasbin.ReloadInterval = 0 // opt out of the background reload goroutine; the test never writes a policy
|
||||
t.Cleanup(func() { mycasbin.ReloadInterval = previousInterval })
|
||||
|
||||
e := mycasbin.Setup(db, tenant)
|
||||
previousEnforcer := sdk.Runtime.GetCasbinByTenant(tenant)
|
||||
sdk.Runtime.SetCasbinByTenant(tenant, e)
|
||||
t.Cleanup(func() { sdk.Runtime.SetCasbinByTenant(tenant, previousEnforcer) })
|
||||
|
||||
return db, tenant
|
||||
}
|
||||
|
||||
// callUpdate drives SysUser.Update the way the router does for an
|
||||
// authenticated, non-admin caller: JWT claims already decoded into the
|
||||
// context (that is jwtauth's job, not this handler's) and a database - but
|
||||
// without AuthCheckRole, since that middleware never runs Enforce for this
|
||||
// route at all.
|
||||
func callUpdate(t *testing.T, db *gorm.DB, tenant string, callerId int, body map[string]interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/sys-user", bytes.NewReader(raw))
|
||||
c.Request.Host = tenant
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
c.Set("db", db)
|
||||
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
|
||||
"identity": float64(callerId),
|
||||
"rolekey": "ordinary-role", // holds no Casbin policy anywhere in this test
|
||||
})
|
||||
|
||||
SysUser{}.Update(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord is the
|
||||
// regression for H6. Before the fix, an ordinary authenticated user could PUT
|
||||
// a body naming another user's id and change that user's roleId - the route
|
||||
// being Casbin-excluded meant no permission check ever ran, and the data
|
||||
// permission scope that would otherwise gate this is off by default.
|
||||
func TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord(t *testing.T) {
|
||||
db, tenant := setupPrivescDB(t)
|
||||
|
||||
victim := models.SysUser{Username: "bob", NickName: "Bob", RoleId: 2, DeptId: 1, Status: "1"}
|
||||
if err := db.Create(&victim).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attacker := models.SysUser{Username: "alice", NickName: "Alice", RoleId: 2, DeptId: 1, Status: "1"}
|
||||
if err := db.Create(&attacker).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const elevatedRoleId = 1 // a role the attacker does not hold and has no policy for
|
||||
|
||||
callUpdate(t, db, tenant, attacker.UserId, map[string]interface{}{
|
||||
"userId": victim.UserId,
|
||||
"username": victim.Username,
|
||||
"nickName": "pwned",
|
||||
"phone": "13800000000",
|
||||
"email": "bob@example.com",
|
||||
"roleId": elevatedRoleId,
|
||||
"deptId": victim.DeptId,
|
||||
"status": victim.Status,
|
||||
})
|
||||
|
||||
var after models.SysUser
|
||||
if err := db.First(&after, victim.UserId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.RoleId == elevatedRoleId {
|
||||
t.Fatalf("an attacker with no Casbin permission on this route escalated the victim's roleId to %d", after.RoleId)
|
||||
}
|
||||
if after.NickName == "pwned" {
|
||||
t.Fatalf("an attacker with no Casbin permission on this route modified another user's record: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_SelfEditCannotChangePrivilegedFields covers the case the
|
||||
// CasbinExclude entry exists for: the personal-center screen has to keep
|
||||
// working for the caller's own record. The fields that screen exposes
|
||||
// (nickName/phone/email/sex) must still save, while roleId/deptId/status stay
|
||||
// whatever the database already had even if the request carries something
|
||||
// else - a compromised or hand-crafted client is the only way that request
|
||||
// would ever differ from what the honest form sends.
|
||||
func TestUpdate_SelfEditCannotChangePrivilegedFields(t *testing.T) {
|
||||
db, tenant := setupPrivescDB(t)
|
||||
|
||||
self := models.SysUser{Username: "carol", NickName: "Carol", RoleId: 2, DeptId: 1, Status: "1"}
|
||||
if err := db.Create(&self).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const elevatedRoleId = 1
|
||||
|
||||
callUpdate(t, db, tenant, self.UserId, map[string]interface{}{
|
||||
"userId": self.UserId,
|
||||
"username": self.Username,
|
||||
"nickName": "Carol Updated",
|
||||
"phone": "13900000000",
|
||||
"email": "carol@example.com",
|
||||
"roleId": elevatedRoleId, // tampered; must not take effect
|
||||
"deptId": self.DeptId,
|
||||
"status": self.Status,
|
||||
})
|
||||
|
||||
var after models.SysUser
|
||||
if err := db.First(&after, self.UserId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.RoleId == elevatedRoleId {
|
||||
t.Fatalf("a self-edit changed the caller's own roleId to %d", after.RoleId)
|
||||
}
|
||||
if after.NickName != "Carol Updated" {
|
||||
t.Fatalf("the legitimate personal-center edit did not go through: %+v", after)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
type DataPermission struct {
|
||||
DataScope string
|
||||
UserId int
|
||||
DeptId int
|
||||
RoleId int
|
||||
}
|
||||
|
||||
func (e *DataPermission) GetDataScope(tableName string, db *gorm.DB) (*gorm.DB, error) {
|
||||
|
||||
if !config.ApplicationConfig.EnableDP {
|
||||
usageStr := `数据权限已经为您` + pkg.Green(`关闭`) + `,如需开启请参考配置文件字段说明`
|
||||
log.Debug("%s\n", usageStr)
|
||||
return db, nil
|
||||
}
|
||||
user := new(SysUser)
|
||||
role := new(SysRole)
|
||||
err := db.Find(user, e.UserId).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
}
|
||||
err = db.Find(role, user.RoleId).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
}
|
||||
if role.DataScope == "2" {
|
||||
db = db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
|
||||
}
|
||||
if role.DataScope == "3" {
|
||||
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
|
||||
}
|
||||
if role.DataScope == "4" {
|
||||
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
|
||||
}
|
||||
if role.DataScope == "5" || role.DataScope == "" {
|
||||
db = db.Where(tableName+".create_by = ?", e.UserId)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
//func DataScopes(tableName string, userId int) func(db *gorm.DB) *gorm.DB {
|
||||
// return func(db *gorm.DB) *gorm.DB {
|
||||
// user := new(SysUser)
|
||||
// role := new(SysRole)
|
||||
// user.UserId = userId
|
||||
// err := db.Find(user, userId).Error
|
||||
// if err != nil {
|
||||
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
// return db
|
||||
// }
|
||||
// err = db.Find(role, user.RoleId).Error
|
||||
// if err != nil {
|
||||
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
// return db
|
||||
// }
|
||||
// if role.DataScope == "2" {
|
||||
// return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
|
||||
// }
|
||||
// if role.DataScope == "3" {
|
||||
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
|
||||
// }
|
||||
// if role.DataScope == "4" {
|
||||
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
|
||||
// }
|
||||
// if role.DataScope == "5" || role.DataScope == "" {
|
||||
// return db.Where(tableName+".create_by = ?", userId)
|
||||
// }
|
||||
// return db
|
||||
// }
|
||||
//}
|
||||
@@ -23,6 +23,10 @@ type SysApi struct {
|
||||
Path string `json:"path" gorm:"size:128;comment:地址"`
|
||||
Action string `json:"action" gorm:"size:16;comment:请求类型"`
|
||||
Type string `json:"type" gorm:"size:16;comment:接口类型"`
|
||||
// AppCode identifies which application's seed.SeedMenus call wrote this
|
||||
// row; empty for the host's own built-in APIs. Same NOT NULL DEFAULT ''
|
||||
// reasoning as SysMenu.AppCode.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_api_app_code;comment:AppCode"`
|
||||
models.ModelTime
|
||||
models.ControlBy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
// The values sys_app.status takes.
|
||||
//
|
||||
// Three states rather than a single "installed", because an install that
|
||||
// stopped partway has to be an observable row rather than the absence of one:
|
||||
// the versions an app installs are separate migration files, and on MySQL a
|
||||
// DDL statement commits the transaction around it - taking an outer
|
||||
// transaction and every savepoint under it with it - so they cannot be
|
||||
// wrapped in one.
|
||||
//
|
||||
// AppInstalling is also what a row reads as after the process was killed
|
||||
// mid-install, which is why it is not treated as "installed" by anything.
|
||||
const (
|
||||
AppInstalling = 1
|
||||
AppInstalled = 2
|
||||
AppFailed = 3
|
||||
)
|
||||
|
||||
// SysApp is the sys_app row model: one row per installed application (PRD
|
||||
// 008 F2). It deliberately does not embed models.ModelTime - see the design
|
||||
// doc (docs-prd/008-应用清单与安装器/数据库变更.md) §1.1 for why an
|
||||
// installed-app registry does not need the millisecond soft-delete marker
|
||||
// every other sys_* table follows. Uninstalling an app deletes its row
|
||||
// outright; a later reinstall creates a fresh one.
|
||||
type SysApp struct {
|
||||
models.Model // Id int, primary key, autoincrement
|
||||
|
||||
// AppCode is the app.Manifest.Code / migration.ForApp / seed.SeedMenus
|
||||
// identity, already lower-cased by migration.NormalizeAppCode before
|
||||
// anything reaches this table. Unique: row existence alone answers G2
|
||||
// ("is app X installed").
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;uniqueIndex:uk_sys_app_app_code;comment:app code"`
|
||||
|
||||
Name string `json:"name" gorm:"size:128;not null;comment:display name, from Manifest.Name"`
|
||||
// Version is the version this row currently reflects - attempted or
|
||||
// confirmed, disambiguated by Status. It does not drive which
|
||||
// migrations run next; sys_migration's per-version rows do that (see
|
||||
// design doc §1.5's resume flow). This field is descriptive, refreshed
|
||||
// from the manifest on every install/upgrade/resume attempt.
|
||||
Version string `json:"version" gorm:"size:32;not null;comment:version this row currently reflects, see Status"`
|
||||
Description string `json:"description" gorm:"size:255;not null;default:'';comment:from Manifest.Description"`
|
||||
Author string `json:"author" gorm:"size:128;not null;default:'';comment:from Manifest.Author"`
|
||||
|
||||
// Requires is a comma-separated list of app codes this app declared as
|
||||
// dependencies (Manifest.Requires). Stored as plain VARCHAR CSV, not
|
||||
// JSON - see design doc §1.3 for why. F8 (P1) is what validates and
|
||||
// orders on this; this batch only stores what the manifest declared.
|
||||
Requires string `json:"requires" gorm:"size:255;not null;default:'';comment:declared dependency app codes, comma separated"`
|
||||
|
||||
// Pricing/License are reserved passthrough fields (PRD 003; PRD 008
|
||||
// open question 1). This batch stores whatever the manifest carries and
|
||||
// does not interpret either one.
|
||||
Pricing string `json:"pricing" gorm:"size:64;not null;default:'';comment:reserved, not interpreted by this batch"`
|
||||
License string `json:"license" gorm:"size:64;not null;default:'';comment:reserved, not interpreted by this batch"`
|
||||
|
||||
// Status: 1=installing 2=installed 3=failed. Three states, not a
|
||||
// single "1=installed", because a partial, stuck install has to be an
|
||||
// observable row rather than "the row doesn't exist yet" - see design
|
||||
// doc §1.5 for why cross-migration-file atomicity is not available on
|
||||
// MySQL (implicit commit on DDL).
|
||||
Status int `json:"status" gorm:"size:4;not null;default:1;comment:1=installing 2=installed 3=failed"`
|
||||
|
||||
// FailedVersion and LastError are DIAGNOSTIC TEXT ONLY - what a human
|
||||
// looking at this row is told about the last failure, nothing more. No
|
||||
// code anywhere may read either one to decide what to do next.
|
||||
//
|
||||
// The question "where should a resume pick up" has exactly one
|
||||
// authoritative answer, and it is not these two columns: subtract
|
||||
// sys_migration's applied rows for this app_code from what the app's
|
||||
// own compiled-in code has registered (migration.Snapshot()/ForApp -
|
||||
// the same set F7's `migrate status` already walks). That answer can
|
||||
// never go stale, because it is not stored anywhere to go stale - it is
|
||||
// recomputed from sys_migration every time it is asked. FailedVersion
|
||||
// is a snapshot of what that computation returned at the moment of
|
||||
// failure, kept only so an operator does not have to go find the
|
||||
// process's logs; if it and a fresh recomputation from sys_migration
|
||||
// ever disagree, sys_migration is right and this column is stale, by
|
||||
// definition, and nothing should ever notice or care except a human
|
||||
// reading the row.
|
||||
FailedVersion string `json:"failedVersion" gorm:"size:64;not null;default:'';comment:diagnostic snapshot only, not a judgment basis; meaningful only when status=3"`
|
||||
LastError string `json:"lastError" gorm:"size:255;not null;default:'';comment:diagnostic text only, not a judgment basis; meaningful only when status=3"`
|
||||
|
||||
// InstalledAt is when this app first reached status=installed - set
|
||||
// once, never moved by a later upgrade (see design doc §1.4). Nullable,
|
||||
// unlike every other column here: a row can exist before it has a
|
||||
// value (a fresh install starts at status=installing). This is not the
|
||||
// deleted_at problem 1786700003000_soft_delete_marker.go fixed - that
|
||||
// column sat inside a unique index, where NULL <> NULL let two live
|
||||
// rows coexist under the same key. InstalledAt is in no index at all,
|
||||
// so nullability here opens no such hole.
|
||||
InstalledAt *time.Time `json:"installedAt" gorm:"comment:first successful install time; null until status first reaches installed"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:last updated time"`
|
||||
|
||||
models.ControlBy // CreateBy/UpdateBy: which operator triggered the attempt
|
||||
}
|
||||
|
||||
func (*SysApp) TableName() string {
|
||||
return "sys_app"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SysAppCasbinGrant is a ledger of casbin_rule rows an app install created,
|
||||
// keyed by the exact natural key casbin_rule itself is unique on. It exists
|
||||
// because casbin_rule is not a table this project owns (see design doc
|
||||
// docs-prd/008-应用清单与安装器/数据库变更.md §2.2): we cannot add an
|
||||
// app_code column to it without that column being silently zeroed the first
|
||||
// time anything calls the gorm-adapter's SavePolicy/SavePolicyCtx. Recording
|
||||
// the natural key here, instead of a foreign key into casbin_rule, is also
|
||||
// what survives SysRole.Update's RemoveFilteredPolicy+re-add cycle for a
|
||||
// role's policies (app/admin/service/sys_role.go): that cycle replaces the
|
||||
// underlying row (a new auto-increment ID) but reproduces the same
|
||||
// (ptype,v0,v1,v2) tuple from the same sys_menu/sys_api data, so a
|
||||
// natural-key match here still finds it. What it does not survive is the
|
||||
// role being renamed, or the tuple being rebuilt from a completely different
|
||||
// source (a future SavePolicy call from outside this seeder) - in both cases
|
||||
// the match legitimately fails, and business rule 3 says the uninstaller
|
||||
// should report and skip, not delete something else that happens to look
|
||||
// the same.
|
||||
type SysAppCasbinGrant struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;index:idx_sys_app_casbin_grant_app_code;comment:app code that created this grant"`
|
||||
|
||||
// Column widths mirror gorm-adapter's own CasbinRule struct exactly, so
|
||||
// a value that fits into casbin_rule always fits here, and the unique
|
||||
// index below matches the one createTable() puts on casbin_rule itself.
|
||||
Ptype string `json:"ptype" gorm:"size:100;not null;uniqueIndex:uk_sys_app_casbin_grant_rule;comment:casbin ptype, 'p' today"`
|
||||
V0 string `json:"v0" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:role_key at grant time"`
|
||||
V1 string `json:"v1" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:api path"`
|
||||
V2 string `json:"v2" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:http method"`
|
||||
V3 string `json:"v3" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"`
|
||||
V4 string `json:"v4" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"`
|
||||
V5 string `json:"v5" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"comment:when this grant was recorded"`
|
||||
}
|
||||
|
||||
func (*SysAppCasbinGrant) TableName() string {
|
||||
return "sys_app_casbin_grant"
|
||||
}
|
||||
@@ -26,6 +26,36 @@ type SysMenu struct {
|
||||
RoleId int `gorm:"-"`
|
||||
Children []SysMenu `json:"children,omitempty" gorm:"-"`
|
||||
IsSelect bool `json:"is_select" gorm:"-"`
|
||||
// AppCode identifies which application's seed.SeedMenus call wrote this
|
||||
// row; empty for the host's own built-in menus. NOT NULL DEFAULT '' for
|
||||
// the same reason sys_migration.app_code is (see contract/models.Migration):
|
||||
// AutoMigrate adding this column to an existing table leaves every
|
||||
// pre-existing row reading back as "" rather than NULL.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_menu_app_code;comment:AppCode"`
|
||||
// SeedCode is the raw seed.MenuSpec.Code this row was created from, kept
|
||||
// so seedMenuTree can ask "did I already write this node" without
|
||||
// relying on MenuName's PascalCase concatenation, which is not
|
||||
// injective (see design doc §1.6). Nullable, unlike AppCode: every row
|
||||
// seed.SeedMenus writes sets a real value, but every pre-existing row -
|
||||
// the host's own hand-placed menus, and every app-seeded row written
|
||||
// before this column existed - has none, and there is no way to
|
||||
// backfill one that means anything. NULL is what lets an unbounded
|
||||
// number of those coexist under the same app_code without tripping the
|
||||
// unique index below: the database never treats two NULLs as equal, so
|
||||
// only rows that do carry a real code participate in the uniqueness
|
||||
// check at all.
|
||||
// uk_sys_menu_app_seed_code_del is created by the migration, not from
|
||||
// this tag, and deliberately: it covers (app_code, seed_code,
|
||||
// deleted_at), and this struct cannot say so. A named uniqueIndex tag
|
||||
// puts every field carrying that name into one index, and deleted_at
|
||||
// comes from the shared ModelTime embed, which no single model can add a
|
||||
// tag to. Naming it here anyway declared a unique index on seed_code
|
||||
// alone under the same name - stricter than the real one, forbidding two
|
||||
// applications from both having a "dir" node - and AutoMigrate on this
|
||||
// model would have created that one first, after which the migration's
|
||||
// HasIndex guard finds the name taken and leaves the wrong index in
|
||||
// place.
|
||||
SeedCode *string `json:"seedCode" gorm:"size:64;comment:raw MenuSpec.Code, null for rows not written through SeedMenus"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
// uk_sys_menu_app_seed_code_del covers (app_code, seed_code, deleted_at) and
|
||||
// is created by 1786700008000, not from a struct tag. It cannot come from a
|
||||
// tag: a named uniqueIndex collects every field carrying that name, and
|
||||
// deleted_at lives in the shared ModelTime embed that no single model can tag.
|
||||
//
|
||||
// Naming it on SeedCode alone anyway produced a unique index on seed_code by
|
||||
// itself under the same name - stricter than the real one - and AutoMigrate
|
||||
// here would create that one, after which the migration's HasIndex guard
|
||||
// finds the name taken and leaves the wrong index in place. Nothing in
|
||||
// production AutoMigrates this model (the initial table migration uses a
|
||||
// frozen snapshot that has neither column), which is why this never showed up
|
||||
// as a broken database; it showed up the first time a test built the schema
|
||||
// from the live model and seeded two applications.
|
||||
func TestSysMenuDeclaresNoSeedCodeIndexOfItsOwn(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&SysMenu{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if db.Migrator().HasIndex(&SysMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
t.Error("AutoMigrate created uk_sys_menu_app_seed_code_del from a tag; " +
|
||||
"the migration's HasIndex guard will now skip the composite index it should create")
|
||||
}
|
||||
|
||||
// Two applications, the same seed code. The real index allows it because
|
||||
// app_code is part of the key; an index on seed_code alone does not.
|
||||
for _, app := range []string{"order", "crm"} {
|
||||
row := SysMenu{MenuName: app + "Dir", AppCode: app, SeedCode: ptr("dir")}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("%s could not use the seed code \"dir\": %v", app, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,19 +42,35 @@ func (e *SysUser) GetId() interface{} {
|
||||
return e.UserId
|
||||
}
|
||||
|
||||
// Encrypt 加密
|
||||
func (e *SysUser) Encrypt() (err error) {
|
||||
// Encrypt hashes Password, unless it already holds a hash.
|
||||
//
|
||||
// The hooks below run on whatever is in the struct, and a user read from the
|
||||
// database carries the stored hash in that field. Hashing it again produces a
|
||||
// hash of a hash, and the password that user knows no longer matches anything:
|
||||
// they cannot log in, and nothing reports an error. The only thing preventing
|
||||
// that today is an Omit("password") on the one update that loads a user first,
|
||||
// which makes every other write to this model one line away from destroying
|
||||
// credentials.
|
||||
//
|
||||
// bcrypt.Cost parses a hash and fails on anything else, so it distinguishes
|
||||
// the two cases without the call site having to say which it is. The cost is
|
||||
// that a password which is itself a well-formed bcrypt hash would be stored
|
||||
// unchanged - a 60-character string beginning "$2a$", not something a person
|
||||
// types, and it grants whoever set it no access they did not already have.
|
||||
func (e *SysUser) Encrypt() error {
|
||||
if e.Password == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(e.Password)); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var hash []byte
|
||||
if hash, err = bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost); err != nil {
|
||||
return
|
||||
} else {
|
||||
e.Password = string(hash)
|
||||
return
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Password = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
|
||||
@@ -62,11 +78,7 @@ func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
|
||||
}
|
||||
|
||||
func (e *SysUser) BeforeUpdate(_ *gorm.DB) error {
|
||||
var err error
|
||||
if e.Password != "" {
|
||||
err = e.Encrypt()
|
||||
}
|
||||
return err
|
||||
return e.Encrypt()
|
||||
}
|
||||
|
||||
func (e *SysUser) AfterFind(_ *gorm.DB) error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const knownPassword = "correct-horse-battery-staple"
|
||||
|
||||
// A user loaded from the database carries the stored hash in Password, and the
|
||||
// hooks run on whatever is in the struct. Hashing it a second time produces a
|
||||
// hash of a hash: the password the user knows stops matching, they cannot log
|
||||
// in, and nothing reports an error.
|
||||
//
|
||||
// Only an Omit("password") on one call site stood between this and every write
|
||||
// to the model. This is the test that removes the need for it.
|
||||
func TestEncryptLeavesAnAlreadyHashedPasswordAlone(t *testing.T) {
|
||||
fresh := SysUser{Password: knownPassword}
|
||||
if err := fresh.Encrypt(); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
stored := fresh.Password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(knownPassword)); err != nil {
|
||||
t.Fatalf("setup failed: the password was not hashed: %v", err)
|
||||
}
|
||||
|
||||
// What a query puts in the struct, and what an update then hands the hook.
|
||||
loaded := SysUser{Password: stored}
|
||||
if err := loaded.Encrypt(); err != nil {
|
||||
t.Fatalf("Encrypt on a loaded user: %v", err)
|
||||
}
|
||||
if loaded.Password != stored {
|
||||
t.Error("Encrypt re-hashed a stored hash; the user can no longer log in")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(loaded.Password), []byte(knownPassword)); err != nil {
|
||||
t.Errorf("the user can no longer log in with their password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: a password that is not a hash still gets hashed, on create
|
||||
// and on update alike.
|
||||
func TestEncryptHashesAPlaintextPassword(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
hook func(*SysUser) error
|
||||
}{
|
||||
{"BeforeCreate", func(u *SysUser) error { return u.BeforeCreate(nil) }},
|
||||
{"BeforeUpdate", func(u *SysUser) error { return u.BeforeUpdate(nil) }},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u := SysUser{Password: knownPassword}
|
||||
if err := c.hook(&u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Password == knownPassword {
|
||||
t.Fatal("the password was stored as it was typed")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(knownPassword)); err != nil {
|
||||
t.Errorf("the stored value does not verify the password: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An empty Password means "not being set", and must not become a hash of "".
|
||||
func TestEncryptIgnoresAnEmptyPassword(t *testing.T) {
|
||||
u := SysUser{}
|
||||
if err := u.Encrypt(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Password != "" {
|
||||
t.Errorf("an unset password became %q", u.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt runs on every update of this model, including the ones that change
|
||||
// something else entirely. What it costs when there is nothing to do is the
|
||||
// difference between a profile update and a bcrypt round; the correctness test
|
||||
// above is what catches a regression, this reports the size of it.
|
||||
func BenchmarkEncrypt(b *testing.B) {
|
||||
fresh := SysUser{Password: knownPassword}
|
||||
if err := fresh.Encrypt(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
b.Run("already hashed", func(b *testing.B) {
|
||||
u := SysUser{Password: fresh.Password}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := u.Encrypt(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("plaintext", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
u := SysUser{Password: knownPassword}
|
||||
if err := u.Encrypt(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,11 +25,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册系统路由
|
||||
InitSysRouter(r, authMiddleware)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,10 @@ func init() {
|
||||
// registerSysApiRouter
|
||||
func registerSysApiRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
api := apis.SysApi{}
|
||||
r := v1.Group("/sys-api").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
// PermissionAction is not optional here: all three handlers below read the
|
||||
// data permission out of the context, and without it they read the zero
|
||||
// value - an unset scope, which Permission now fails closed on.
|
||||
r := v1.Group("/sys-api").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
{
|
||||
r.GET("", api.GetPage)
|
||||
r.GET("/:id", api.Get)
|
||||
|
||||
@@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r1.GET("/deptTree", api.Get2Tree)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
//r1.GET("/menuids", api.GetMenuIDS)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
r.PUT("/:id", api.Update)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
{
|
||||
v1auth.GET("/getinfo", api.GetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
|
||||
// SysDeptGetPageReq 列表或者搜索使用结构体
|
||||
type SysDeptGetPageReq struct {
|
||||
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
|
||||
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
|
||||
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
|
||||
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
|
||||
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
|
||||
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
|
||||
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
|
||||
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
|
||||
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
|
||||
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
|
||||
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
|
||||
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
|
||||
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
|
||||
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
|
||||
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
|
||||
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
|
||||
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
|
||||
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
|
||||
}
|
||||
|
||||
func (m *SysDeptGetPageReq) GetNeedSearch() interface{} {
|
||||
|
||||
@@ -54,4 +54,4 @@ type SysLoginLogDeleteReq struct {
|
||||
|
||||
func (s *SysLoginLogDeleteReq) GetId() interface{} {
|
||||
return s.Ids
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ import (
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/common/dto"
|
||||
"go-admin/common/global"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Deprecated: use global.OperaStatusEnabled / global.OperaStatusDisabled.
|
||||
// These two names are kept - misspelling and all - because forks import them;
|
||||
// the values moved to common/global so common/middleware no longer has to
|
||||
// import this package. See docs/contract.md.
|
||||
const (
|
||||
OperaStatusEnabel = "1" // 状态-正常
|
||||
OperaStatusDisable = "2" // 状态-关闭
|
||||
OperaStatusEnabel = global.OperaStatusEnabled // 状态-正常
|
||||
OperaStatusDisable = global.OperaStatusDisabled // 状态-关闭
|
||||
)
|
||||
|
||||
type SysOperaLogGetPageReq struct {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/common/global"
|
||||
)
|
||||
|
||||
// The values moved to common/global so common/middleware would stop importing
|
||||
// this package; these two names stayed behind as aliases, misspelling and all,
|
||||
// because forks import them.
|
||||
//
|
||||
// If they ever drift apart, rows written through the two spellings land in
|
||||
// different buckets and the operation-log filter silently misses half of them.
|
||||
func TestDeprecatedStatusAliasesStillMatch(t *testing.T) {
|
||||
if OperaStatusEnabel != global.OperaStatusEnabled {
|
||||
t.Errorf("OperaStatusEnabel = %q, global.OperaStatusEnabled = %q",
|
||||
OperaStatusEnabel, global.OperaStatusEnabled)
|
||||
}
|
||||
if OperaStatusDisable != global.OperaStatusDisabled {
|
||||
t.Errorf("OperaStatusDisable = %q, global.OperaStatusDisabled = %q",
|
||||
OperaStatusDisable, global.OperaStatusDisabled)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ type SysRoleInsertReq struct {
|
||||
Flag string `form:"flag" comment:"标记"` // 标记
|
||||
Remark string `form:"remark" comment:"备注"` // 备注
|
||||
Admin bool `form:"admin" comment:"是否管理员"`
|
||||
DataScope string `form:"dataScope"`
|
||||
DataScope string `form:"dataScope" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
|
||||
SysMenu []models.SysMenu `form:"sysMenu"`
|
||||
MenuIds []int `form:"menuIds"`
|
||||
SysDept []models.SysDept `form:"sysDept"`
|
||||
@@ -79,7 +79,7 @@ type SysRoleUpdateReq struct {
|
||||
Flag string `form:"flag" comment:"标记"` // 标记
|
||||
Remark string `form:"remark" comment:"备注"` // 备注
|
||||
Admin bool `form:"admin" comment:"是否管理员"`
|
||||
DataScope string `form:"dataScope"`
|
||||
DataScope string `form:"dataScope" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
|
||||
SysMenu []models.SysMenu `form:"sysMenu"`
|
||||
MenuIds []int `form:"menuIds"`
|
||||
SysDept []models.SysDept `form:"sysDept"`
|
||||
@@ -147,7 +147,7 @@ func (s *SysRoleDeleteReq) GetId() interface{} {
|
||||
// RoleDataScopeReq 角色数据权限修改
|
||||
type RoleDataScopeReq struct {
|
||||
RoleId int `json:"roleId" binding:"required"`
|
||||
DataScope string `json:"dataScope" binding:"required"`
|
||||
DataScope string `json:"dataScope" binding:"required" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
|
||||
DeptIds []int `json:"deptIds"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
vd "github.com/bytedance/go-tagexpr/v2/validator"
|
||||
)
|
||||
|
||||
// api.Bind calls vd.Validate unconditionally on every request, regardless of
|
||||
// which binding stage ran, so a vd tag on DataScope is enough to reject
|
||||
// anything actions.Permission's fail-closed default would otherwise have to
|
||||
// deal with. PRD 006 F14/H2 named this the real trigger for the default
|
||||
// branch: SysRoleInsertReq.DataScope had no validation at all, so leaving
|
||||
// dataScope out of a create-role request wrote an empty string straight to
|
||||
// sys_role.
|
||||
func TestDataScopeRejectsWhatPermissionCannotRecognize(t *testing.T) {
|
||||
invalid := []string{"", "0", "6", "all", " 1", "1 "}
|
||||
valid := []string{"1", "2", "3", "4", "5"}
|
||||
|
||||
t.Run("SysRoleInsertReq", func(t *testing.T) {
|
||||
for _, s := range invalid {
|
||||
req := SysRoleInsertReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err == nil {
|
||||
t.Errorf("DataScope %q was accepted, want rejected", s)
|
||||
}
|
||||
}
|
||||
for _, s := range valid {
|
||||
req := SysRoleInsertReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err != nil {
|
||||
t.Errorf("DataScope %q was rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SysRoleUpdateReq", func(t *testing.T) {
|
||||
for _, s := range invalid {
|
||||
req := SysRoleUpdateReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err == nil {
|
||||
t.Errorf("DataScope %q was accepted, want rejected", s)
|
||||
}
|
||||
}
|
||||
for _, s := range valid {
|
||||
req := SysRoleUpdateReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err != nil {
|
||||
t.Errorf("DataScope %q was rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RoleDataScopeReq", func(t *testing.T) {
|
||||
for _, s := range invalid {
|
||||
req := RoleDataScopeReq{RoleId: 1, DataScope: s}
|
||||
if err := vd.Validate(&req); err == nil {
|
||||
t.Errorf("DataScope %q was accepted, want rejected", s)
|
||||
}
|
||||
}
|
||||
for _, s := range valid {
|
||||
req := RoleDataScopeReq{RoleId: 1, DataScope: s}
|
||||
if err := vd.Validate(&req); err != nil {
|
||||
t.Errorf("DataScope %q was rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// adminSeeder is go-admin's own implementation of seed.Seeder: it turns the
|
||||
// MenuSpec/ApiSpec values a third-party application asks for into rows
|
||||
// across the four tables a visible, working menu entry needs - sys_api,
|
||||
// sys_menu, sys_menu_api_rule, and sys_role_menu/casbin_rule - following the
|
||||
// same shape cmd/migrate/migration/version/1786700001000_demo_menu.go
|
||||
// already hand-writes for the host's own demo module.
|
||||
//
|
||||
// See go-admin-core's docs/contract.md, "Application-supplied menu and API
|
||||
// entries", for the requirements this satisfies, and the security note on
|
||||
// seed.Seeder for what this boundary does and does not protect against: an
|
||||
// application already holds the same *gorm.DB this receives and could write
|
||||
// sys_menu/sys_api/casbin_rule directly, bypassing this entirely.
|
||||
type adminSeeder struct{}
|
||||
|
||||
func init() {
|
||||
seed.RegisterSeeder(adminSeeder{})
|
||||
}
|
||||
|
||||
// adminRoleKey is the role every seeded menu is granted to. This mirrors
|
||||
// 1786700001000_demo_menu.go's own convention rather than inventing a
|
||||
// second one: MenuSpec carries no "which roles should see this" field for a
|
||||
// Seeder to consult instead, and admin is the one role guaranteed to exist
|
||||
// once the framework's own seed data has run.
|
||||
const adminRoleKey = "admin"
|
||||
|
||||
// menuSortRange is what sys_menu.sort's column type actually holds.
|
||||
//
|
||||
// sort is `gorm:"size:4"`, which MySQL builds as a tinyint (-128..127);
|
||||
// sqlite ignores the width and accepts anything, so this only ever surfaces
|
||||
// on a real install, mid-migration, as Error 1264 - by which point the
|
||||
// migration has already run other, non-transactional DDL that will not be
|
||||
// retried. tools/checksilent's menu-sort-overflow check catches this for
|
||||
// every MenuSpec-shaped literal committed to this repository, but it walks
|
||||
// the repository's own source tree: a third-party application living in the
|
||||
// module cache is invisible to it. This is the equivalent check for that
|
||||
// application, run when its migration actually calls SeedMenus rather than
|
||||
// never.
|
||||
const (
|
||||
menuSortMin = -128
|
||||
menuSortMax = 127
|
||||
)
|
||||
|
||||
func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error {
|
||||
apiRows, err := seedApis(tx, appCode, apis)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: app %q: apis: %w", appCode, err)
|
||||
}
|
||||
|
||||
menuIDs, err := seedMenuTree(tx, appCode, menus, apiRows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: app %q: menus: %w", appCode, err)
|
||||
}
|
||||
|
||||
// Not `len(menuIDs) == 0`: grantToAdminRole grants two independent
|
||||
// things, and an application is free to register apis without menus -
|
||||
// endpoints another service calls, or a UI mounted somewhere else.
|
||||
// Skipping the whole call on an empty menu list wrote the sys_api rows
|
||||
// and then no casbin rule for them, so those endpoints were denied to
|
||||
// everyone, admin included, with a migration that reported success.
|
||||
if len(menuIDs) == 0 && len(apiRows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return grantToAdminRole(tx, appCode, menuIDs, apiRows)
|
||||
}
|
||||
|
||||
// seedApis writes one sys_api row per ApiSpec and returns them keyed by
|
||||
// ApiSpec.Code, so seedMenuTree can resolve a MenuSpec's ApiCodes into the
|
||||
// rows sys_menu_api_rule needs to reference.
|
||||
//
|
||||
// sys_api.id is left to autoincrement rather than assigned by the caller,
|
||||
// unlike 1786700001000_demo_menu.go's hand-picked ids: that migration is
|
||||
// the one file tools/checksilent's menu-id-collision check can see, because
|
||||
// it lives in this repository; nothing plays that role for a third-party
|
||||
// application's ids in the module cache. Never accepting a caller-chosen id
|
||||
// here removes the collision this Seeder has no way to detect instead of
|
||||
// trying to detect it after the fact.
|
||||
//
|
||||
// The natural key is (app_code, path, action) - the same three columns
|
||||
// 1786700002000_remove_refresh_token_api.go already used to identify a
|
||||
// single API by hand, and the ones 1786700008000_seed_natural_keys.go put a
|
||||
// unique index on. Before inserting, this looks for a live row (deleted_at
|
||||
// = 0, applied automatically by the soft-delete plugin on every query
|
||||
// against models.SysApi) already holding that key and reuses it instead of
|
||||
// inserting a second one - see the design doc §1.6: a migration retried
|
||||
// after a partial failure previously re-ran this as a bare tx.Create and
|
||||
// produced duplicate rows on the demo site.
|
||||
//
|
||||
// Unlike seedMenuTree's reuse branch, this one has nothing left to repair
|
||||
// after finding an existing row: models.SysApi carries no association
|
||||
// (nothing like SysMenu's many2many SysApi field) and this function writes
|
||||
// nothing beyond the row itself - no second statement comparable to
|
||||
// seedMenuTree's paths UPDATE follows tx.Create below. An interrupted retry
|
||||
// can therefore only ever find this row complete or not find it at all.
|
||||
func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) {
|
||||
seen := make(map[string]bool, len(apis))
|
||||
rows := make(map[string]models.SysApi, len(apis))
|
||||
for _, a := range apis {
|
||||
if a.Code == "" {
|
||||
return nil, errors.New("ApiSpec.Code must not be empty")
|
||||
}
|
||||
if seen[a.Code] {
|
||||
return nil, fmt.Errorf("duplicate ApiSpec.Code %q", a.Code)
|
||||
}
|
||||
seen[a.Code] = true
|
||||
|
||||
var existing models.SysApi
|
||||
err := tx.Where("app_code = ? AND path = ? AND action = ?", appCode, a.Path, a.Method).
|
||||
First(&existing).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
rows[a.Code] = existing
|
||||
continue
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// Not seen yet; fall through to insert it.
|
||||
default:
|
||||
return nil, fmt.Errorf("api %q: checking for an existing row: %w", a.Code, err)
|
||||
}
|
||||
|
||||
row := models.SysApi{
|
||||
Handle: a.Handle,
|
||||
Title: a.Title,
|
||||
Path: a.Path,
|
||||
Action: a.Method,
|
||||
Type: "SYS",
|
||||
AppCode: appCode,
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("api %q: %w", a.Code, err)
|
||||
}
|
||||
rows[a.Code] = row
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// seedMenuTree writes one sys_menu row per MenuSpec, resolving Parent/Code
|
||||
// references into parent_id/paths, and returns every menu id created so the
|
||||
// caller can grant them to a role.
|
||||
//
|
||||
// Specs do not have to be given in parent-before-child order: this makes
|
||||
// repeated passes over the remaining specs, creating whichever ones have
|
||||
// their Parent (if any) already created, until every spec is placed. A
|
||||
// spec whose Parent never resolves - naming a Code missing from this call,
|
||||
// or only reachable through a cycle - stops making progress and is reported
|
||||
// rather than looping forever.
|
||||
func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows map[string]models.SysApi) ([]int, error) {
|
||||
byCode := make(map[string]seed.MenuSpec, len(specs))
|
||||
for _, s := range specs {
|
||||
if s.Code == "" {
|
||||
return nil, errors.New("MenuSpec.Code must not be empty")
|
||||
}
|
||||
if _, dup := byCode[s.Code]; dup {
|
||||
return nil, fmt.Errorf("duplicate MenuSpec.Code %q", s.Code)
|
||||
}
|
||||
if err := validateMenuSpec(s); err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", s.Code, err)
|
||||
}
|
||||
byCode[s.Code] = s
|
||||
}
|
||||
|
||||
created := make(map[string]models.SysMenu, len(specs))
|
||||
ids := make([]int, 0, len(specs))
|
||||
|
||||
for len(created) < len(specs) {
|
||||
progressed := false
|
||||
for _, s := range specs {
|
||||
if _, done := created[s.Code]; done {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolved before the idempotency check below, whether or not
|
||||
// this spec's own row turns out to already exist: repairing an
|
||||
// existing-but-incomplete row's paths needs the parent's
|
||||
// already-resolved Paths exactly as much as creating a fresh
|
||||
// row does (see repairExistingMenu), so both have to wait for
|
||||
// it the same way.
|
||||
var parentRow models.SysMenu
|
||||
if s.Parent != "" {
|
||||
parent, ok := created[s.Parent]
|
||||
if !ok {
|
||||
if _, exists := byCode[s.Parent]; !exists {
|
||||
return nil, fmt.Errorf("%q: Parent %q is not a Code in this call", s.Code, s.Parent)
|
||||
}
|
||||
continue // s.Parent exists but has not been created yet; retry next pass
|
||||
}
|
||||
parentRow = parent
|
||||
}
|
||||
|
||||
// Idempotency check: does this node already have a row, from
|
||||
// an earlier, possibly-interrupted attempt? The natural key is
|
||||
// (app_code, seed_code) - menu_name's PascalCase concatenation
|
||||
// is not injective and cannot be used for this (see menuName's
|
||||
// doc comment and the design doc §1.6). Only a live row counts;
|
||||
// the soft-delete plugin scopes deleted_at = 0 automatically on
|
||||
// every query against models.SysMenu.
|
||||
var existing models.SysMenu
|
||||
found := false
|
||||
err := tx.Where("app_code = ? AND seed_code = ?", appCode, s.Code).First(&existing).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
found = true
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// Nothing under the natural key. It may still be here from
|
||||
// before seed_code existed, under the name that identified
|
||||
// it then.
|
||||
existing, found, err = adoptLegacyMenu(tx, appCode, s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", s.Code, err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%q: checking for an existing row: %w", s.Code, err)
|
||||
}
|
||||
if found {
|
||||
row, err := repairExistingMenu(tx, existing, appCode, s, parentRow, apiRows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q: repairing an existing row: %w", s.Code, err)
|
||||
}
|
||||
created[s.Code] = row
|
||||
ids = append(ids, row.MenuId)
|
||||
progressed = true
|
||||
continue
|
||||
}
|
||||
|
||||
row := menuRowFor(appCode, s, parentRow)
|
||||
for _, code := range s.ApiCodes {
|
||||
api, ok := apiRows[code]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q: ApiCodes references %q, which is not an ApiSpec.Code in this call", s.Code, code)
|
||||
}
|
||||
// The full row, not just {Id: api.Id}: gorm's many2many
|
||||
// association save upserts an associated row whose primary
|
||||
// key is already set, so a stub carrying only Id would
|
||||
// overwrite every other column of an sys_api row this same
|
||||
// call just wrote with zero values.
|
||||
row.SysApi = append(row.SysApi, api)
|
||||
}
|
||||
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", s.Code, err)
|
||||
}
|
||||
|
||||
// paths is a materialized path from the root ("/0"), built from
|
||||
// ids that only exist once the row above is created - the same
|
||||
// two-step create-then-update 1786700001000_demo_menu.go's
|
||||
// hand-assigned ids let it do in one literal, sequenced here
|
||||
// instead.
|
||||
row.Paths = expectedPaths(row.MenuId, s.Parent, parentRow)
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
|
||||
Update("paths", row.Paths).Error; err != nil {
|
||||
return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err)
|
||||
}
|
||||
|
||||
created[s.Code] = row
|
||||
ids = append(ids, row.MenuId)
|
||||
progressed = true
|
||||
}
|
||||
if !progressed {
|
||||
return nil, fmt.Errorf("unresolved Parent reference(s) among %d remaining spec(s); check for a cycle", len(specs)-len(created))
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// expectedPaths is the materialized path a fresh insert of menuID under
|
||||
// parent (or at the root, if parent is "") computes - factored out so
|
||||
// repairExistingMenu can ask the same question about a row it did not just
|
||||
// create.
|
||||
func expectedPaths(menuID int, parent string, parentRow models.SysMenu) string {
|
||||
if parent == "" {
|
||||
return "/0/" + strconv.Itoa(menuID)
|
||||
}
|
||||
return parentRow.Paths + "/" + strconv.Itoa(menuID)
|
||||
}
|
||||
|
||||
// repairExistingMenu brings a row seedMenuTree's idempotency check found up
|
||||
// to what a fresh insert of the same spec would have produced.
|
||||
//
|
||||
// A row can be found and still be incomplete: tx.Create's own association
|
||||
// write (the sys_menu_api_rule bindings from row.SysApi) and the paths
|
||||
// UPDATE that follows it are each their own statement, and design doc §1.5
|
||||
// establishes that nothing after the first DDL in a migration function can
|
||||
// be rolled back together - a process interrupted between the row insert
|
||||
// and either of those two steps leaves exactly this row: present, findable
|
||||
// by its natural key, but missing what makes it a working menu entry. A
|
||||
// retry that only checked "does the row exist" and stopped there would
|
||||
// report success while the sys_menu_api_rule binding stays missing (the
|
||||
// api is granted to no one) or paths stays empty (a materialized-path
|
||||
// break that orphans the rest of the subtree from the root) - as silent as
|
||||
// the duplicate-row defect the idempotency check itself was written to
|
||||
// close.
|
||||
//
|
||||
// Both checks are read-before-write, so a row that is already complete -
|
||||
// the ordinary case on every retry after the first successful one - causes
|
||||
// no writes at all: existing.Paths already equals what expectedPaths
|
||||
// computes, and the sys_menu_api_rule INSERT is itself guarded by
|
||||
// WHERE NOT EXISTS, the same idempotent-insert shape grantToAdminRole
|
||||
// already uses for sys_role_menu/casbin_rule. Never DELETEs an existing
|
||||
// binding to rebuild it - that is the FullSaveAssociations mistake
|
||||
// sys_role.go's SysRole.Update makes for sys_role_menu/casbin_rule
|
||||
// (app/admin/service/sys_role.go:148-153), the exact pattern this design
|
||||
// went out of its way to avoid for the tables that do use it.
|
||||
//
|
||||
// Insert-only cuts both ways, deliberately. A binding an administrator
|
||||
// added by hand through the menu management UI, for an api never in
|
||||
// s.ApiCodes at all, is never touched by this loop and survives every
|
||||
// later retry (TestSeedMenusPreservesAHandAddedBinding is the reproduction
|
||||
// case for the opposite mistake: delete-then-reinsert wipes it silently,
|
||||
// the same shape as sys_role_menu/casbin_rule getting zeroed by a role
|
||||
// edit, just with this code as the actor instead of the victim). The
|
||||
// converse case - a MenuSpec that used to list an ApiCode and no longer
|
||||
// does - is not handled here either, and that half is intentional rather
|
||||
// than an oversight: this loop only ever adds rows for codes the *current*
|
||||
// call's ApiCodes names, so a binding for a code an earlier version
|
||||
// granted and the current one dropped is left in place, stale. Reconciling
|
||||
// that is deleting something, which needs the same certainty about
|
||||
// ownership uninstall's design (see design doc §5) already requires -
|
||||
// this function has no way to tell "stale, from an older version of this
|
||||
// same app" apart from "hand-added, for a reason", and business rule 3
|
||||
// ("uninstall deletes only what it can attribute with certainty") applies
|
||||
// here just as much as it does there. Reconciling stale seed-driven
|
||||
// bindings, if it is ever wanted, belongs in the upgrade path with that
|
||||
// same ownership check - not silently inside every retry of every install.
|
||||
func repairExistingMenu(tx *gorm.DB, existing models.SysMenu, appCode string, s seed.MenuSpec, parentRow models.SysMenu, apiRows map[string]models.SysApi) (models.SysMenu, error) {
|
||||
// Every column the spec decides, not just the two this used to touch. A
|
||||
// menu whose parent was removed and reseeded kept parent_id pointing at
|
||||
// the dead row while its paths named the new one, and the tree is built
|
||||
// from parent_id - so the menu vanished from the sidebar with the
|
||||
// migration reporting success. An application that renamed a menu or
|
||||
// moved its component between versions had its change silently ignored
|
||||
// for the same reason: nothing here wrote those columns.
|
||||
want := menuRowFor(appCode, s, parentRow)
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", existing.MenuId).
|
||||
Select(specMenuFields).Updates(want).Error; err != nil {
|
||||
return models.SysMenu{}, fmt.Errorf("bringing the row up to the spec: %w", err)
|
||||
}
|
||||
want.MenuId = existing.MenuId
|
||||
want.Paths = existing.Paths
|
||||
want.Visible, want.IsFrame = existing.Visible, existing.IsFrame
|
||||
|
||||
if err := repairPaths(tx, &want, s, parentRow); err != nil {
|
||||
return models.SysMenu{}, err
|
||||
}
|
||||
existing = want
|
||||
|
||||
for _, code := range s.ApiCodes {
|
||||
api, ok := apiRows[code]
|
||||
if !ok {
|
||||
return models.SysMenu{}, fmt.Errorf("ApiCodes references %q, which is not an ApiSpec.Code in this call", code)
|
||||
}
|
||||
if err := tx.Exec(
|
||||
"INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_menu_api_rule WHERE sys_menu_menu_id = ? AND sys_api_id = ?)",
|
||||
existing.MenuId, api.Id, existing.MenuId, api.Id,
|
||||
).Error; err != nil {
|
||||
return models.SysMenu{}, fmt.Errorf("binding %q: %w", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// validateMenuSpec rejects the malformed input tools/checksilent's
|
||||
// menu-sort-overflow and Kind-adjacent checks would catch for an in-tree
|
||||
// seed but cannot for a third-party application's - see menuSortRange's doc
|
||||
// comment.
|
||||
func validateMenuSpec(s seed.MenuSpec) error {
|
||||
switch s.Kind {
|
||||
case contractmodels.Directory, contractmodels.Menu, contractmodels.Button:
|
||||
default:
|
||||
return fmt.Errorf("Kind %q is not one of Directory/Menu/Button", s.Kind)
|
||||
}
|
||||
if s.Sort < menuSortMin || s.Sort > menuSortMax {
|
||||
return fmt.Errorf("Sort %d does not fit sys_menu.sort's tinyint column (%d..%d)", s.Sort, menuSortMin, menuSortMax)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// menuName synthesizes sys_menu.menu_name from appCode and the spec's Code,
|
||||
// since MenuSpec carries no field of its own for it - contract/seed's
|
||||
// package doc says a MenuSpec is what rendering a menu and checking a
|
||||
// button permission need, not a mirror of sys_menu's columns.
|
||||
//
|
||||
// PascalCasing both and concatenating them, rather than using Code alone,
|
||||
// is what keeps two applications that both picked the plain word "list" as
|
||||
// a Code from producing the identical menu_name: the frontend's keep-alive
|
||||
// cache matches a route by this exact string, not by (appCode, Code), so a
|
||||
// collision there is a UI bug, not a database error, and nothing else here
|
||||
// would ever surface it.
|
||||
func menuName(appCode, code string) string {
|
||||
return pascalCase(appCode) + pascalCase(code)
|
||||
}
|
||||
|
||||
func pascalCase(s string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' }) {
|
||||
b.WriteString(strings.ToUpper(part[:1]))
|
||||
b.WriteString(part[1:])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// grantToAdminRole is sys_role_menu and casbin_rule: the two tables
|
||||
// go-admin-core's contract.md requires alongside sys_menu/sys_api, without
|
||||
// which a seeded menu is invisible to every role and its apis are
|
||||
// authorized for no one.
|
||||
//
|
||||
// It follows 1786700001000_demo_menu.go's exact pattern, including
|
||||
// tolerating a missing admin role: a database that has not yet run the
|
||||
// framework's own seed data (config/db.sql, inside 1599190683659_tables.go)
|
||||
// has nothing to grant to yet, and namespacedKey's ordering guarantee - every
|
||||
// framework migration sorts before every app-prefixed one - means that
|
||||
// should not happen in practice, but failing this call over it would be
|
||||
// worse than a menu with no grant yet.
|
||||
func grantToAdminRole(tx *gorm.DB, appCode string, menuIDs []int, apiRows map[string]models.SysApi) error {
|
||||
var role models.SysRole
|
||||
if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range menuIDs {
|
||||
if err := tx.Exec(
|
||||
"INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)",
|
||||
role.RoleId, id, role.RoleId, id,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range apiRows {
|
||||
res := tx.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
|
||||
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
|
||||
)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// The policy was already there, so this install did not create
|
||||
// it and it is not this app's to take away. Leaving it out of
|
||||
// the ledger is what makes an uninstall report it instead of
|
||||
// deleting it.
|
||||
//
|
||||
// The two ways this can be wrong are not equally bad, which is
|
||||
// what settles it. Under-recording leaves a policy behind and
|
||||
// the uninstall says so, because a policy naming an app's own
|
||||
// path with no ledger entry is exactly what it lists as an
|
||||
// orphan. Over-recording deletes a grant somebody else made,
|
||||
// silently. Between a visible leftover and an invisible
|
||||
// deletion of somebody's authorization, take the leftover.
|
||||
continue
|
||||
}
|
||||
if err := recordGrant(tx, appCode, role.RoleKey, a.Path, a.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordGrant writes down that this application's install created one casbin
|
||||
// policy, keyed by the same tuple casbin_rule is unique on.
|
||||
//
|
||||
// A ledger rather than a column on casbin_rule, because casbin_rule is not
|
||||
// this project's table: the gorm adapter's SavePolicy truncates it and writes
|
||||
// it back from memory, which would drop any column added here, and
|
||||
// SysRole.Update replaces a role's policy rows wholesale. The tuple survives
|
||||
// both, because both rebuild it from the same sys_menu/sys_api data.
|
||||
//
|
||||
// Written with the same INSERT ... WHERE NOT EXISTS shape as the policy above
|
||||
// rather than a plain insert: the ledger's unique index covers the tuple
|
||||
// alone, so a duplicate would abort the whole seed instead of being the
|
||||
// no-op it should be.
|
||||
func recordGrant(tx *gorm.DB, appCode, roleKey, path, action string) error {
|
||||
return tx.Exec(
|
||||
"INSERT INTO sys_app_casbin_grant (app_code, ptype, v0, v1, v2, v3, v4, v5, created_at) "+
|
||||
"SELECT ?, 'p', ?, ?, ?, '', '', '', ? WHERE NOT EXISTS "+
|
||||
"(SELECT 1 FROM sys_app_casbin_grant WHERE ptype='p' AND v0=? AND v1=? AND v2=? AND v3='' AND v4='' AND v5='')",
|
||||
appCode, roleKey, path, action, time.Now(), roleKey, path, action,
|
||||
).Error
|
||||
}
|
||||
|
||||
// specMenuFields are the sys_menu columns a MenuSpec decides, and the only
|
||||
// ones a reseed rewrites on a row that is already there.
|
||||
//
|
||||
// Visible and IsFrame are not in the list. They are seeding defaults the
|
||||
// application never expressed, so an administrator who hid a seeded menu
|
||||
// keeps it hidden. app_code and seed_code are not either: they are the
|
||||
// natural key the row was found by, and writing them back would be writing
|
||||
// what was just matched.
|
||||
var specMenuFields = []string{
|
||||
"MenuName", "Title", "Icon", "Path", "MenuType",
|
||||
"Permission", "ParentId", "Component", "Sort",
|
||||
}
|
||||
|
||||
// menuRowFor is the row a MenuSpec describes. One definition, so the insert
|
||||
// path and the repair path cannot drift into disagreeing about what a spec
|
||||
// decides.
|
||||
func menuRowFor(appCode string, s seed.MenuSpec, parentRow models.SysMenu) models.SysMenu {
|
||||
seedCode := s.Code
|
||||
return models.SysMenu{
|
||||
MenuName: menuName(appCode, s.Code),
|
||||
Title: s.Title,
|
||||
Icon: s.Icon,
|
||||
Path: s.Path,
|
||||
MenuType: s.Kind,
|
||||
Permission: s.Permission,
|
||||
ParentId: parentRow.MenuId,
|
||||
Component: s.Component,
|
||||
Sort: s.Sort,
|
||||
// Visible "0" is shown, not hidden - the same defaults
|
||||
// 1786700001000_demo_menu.go seeds its own menu with. A freshly
|
||||
// installed application's menu should not need an administrator to
|
||||
// first find and unhide it. Only written when the row is created;
|
||||
// see specMenuFields.
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
AppCode: appCode,
|
||||
SeedCode: &seedCode,
|
||||
}
|
||||
}
|
||||
|
||||
// repairPaths writes row.Paths, and moves whatever is underneath it.
|
||||
//
|
||||
// The subtree matters because it is not all in this call's specs: a menu an
|
||||
// administrator added under a seeded one keeps the old prefix, and nothing
|
||||
// else in the codebase would ever rewrite it. SysMenu.Update does the same
|
||||
// cascade for the same column when somebody moves a menu by hand.
|
||||
//
|
||||
// The predicate is the row itself or a row strictly under it, rather than
|
||||
// `paths LIKE old || '%'`, which also matches /0/10 when old is /0/1.
|
||||
func repairPaths(tx *gorm.DB, row *models.SysMenu, s seed.MenuSpec, parentRow models.SysMenu) error {
|
||||
want := expectedPaths(row.MenuId, s.Parent, parentRow)
|
||||
old := row.Paths
|
||||
if old == want {
|
||||
return nil
|
||||
}
|
||||
if old == "" {
|
||||
// A row whose paths was never written - an interrupted create. It
|
||||
// has no subtree to speak of, and `LIKE '/%'` would match the whole
|
||||
// table.
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
|
||||
Update("paths", want).Error; err != nil {
|
||||
return fmt.Errorf("writing paths: %w", err)
|
||||
}
|
||||
row.Paths = want
|
||||
return nil
|
||||
}
|
||||
|
||||
var subtree []models.SysMenu
|
||||
if err := tx.Where("paths = ? OR paths LIKE ?", old, old+"/%").Find(&subtree).Error; err != nil {
|
||||
return fmt.Errorf("reading the subtree under %s: %w", old, err)
|
||||
}
|
||||
for _, d := range subtree {
|
||||
moved := want + strings.TrimPrefix(d.Paths, old)
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", d.MenuId).
|
||||
Update("paths", moved).Error; err != nil {
|
||||
return fmt.Errorf("moving %d from %s to %s: %w", d.MenuId, d.Paths, moved, err)
|
||||
}
|
||||
}
|
||||
row.Paths = want
|
||||
return nil
|
||||
}
|
||||
|
||||
// adoptLegacyMenu claims a row this application wrote before sys_menu had a
|
||||
// seed_code column, so a reseed repairs it instead of inserting a second copy
|
||||
// beside it.
|
||||
//
|
||||
// 1786700008000 added the column and left it NULL on every row already there,
|
||||
// which is right for the host's own hand-placed menus - there is nothing to
|
||||
// derive one from. An application's rows are in that population too, and for
|
||||
// those the value is derivable, because menu_name is what identified them
|
||||
// before the column existed. Without this the natural-key lookup misses them,
|
||||
// the seed inserts a duplicate, and the unique index cannot object: NULL
|
||||
// never collides.
|
||||
//
|
||||
// Ambiguity is refused rather than guessed. menuName concatenates two
|
||||
// pascalCase strings and pascalCase is not injective, so two specs can land
|
||||
// on one name; picking one of several rows would attach an application's
|
||||
// menu to whichever the database returned first.
|
||||
func adoptLegacyMenu(tx *gorm.DB, appCode string, s seed.MenuSpec) (models.SysMenu, bool, error) {
|
||||
name := menuName(appCode, s.Code)
|
||||
var rows []models.SysMenu
|
||||
if err := tx.Where("app_code = ? AND menu_name = ? AND seed_code IS NULL", appCode, name).
|
||||
Find(&rows).Error; err != nil {
|
||||
return models.SysMenu{}, false, fmt.Errorf("looking for a row written before seed_code existed: %w", err)
|
||||
}
|
||||
switch len(rows) {
|
||||
case 0:
|
||||
return models.SysMenu{}, false, nil
|
||||
case 1:
|
||||
default:
|
||||
return models.SysMenu{}, false, fmt.Errorf(
|
||||
"%d rows carry menu_name %q with no seed_code; which of them belongs to %q cannot be decided here, because menuName is not reversible - reconcile them by hand",
|
||||
len(rows), name, s.Code)
|
||||
}
|
||||
|
||||
seedCode := s.Code
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", rows[0].MenuId).
|
||||
Update("seed_code", seedCode).Error; err != nil {
|
||||
return models.SysMenu{}, false, fmt.Errorf("claiming the row written before seed_code existed: %w", err)
|
||||
}
|
||||
rows[0].SeedCode = &seedCode
|
||||
return rows[0], true, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
@@ -74,9 +76,18 @@ func (e *SysApi) Get(d *dto.SysApiGetReq, p *actions.DataPermission, model *mode
|
||||
// Update 修改SysApi对象
|
||||
func (e *SysApi) Update(c *dto.SysApiUpdateReq, p *actions.DataPermission) error {
|
||||
var model = models.SysApi{}
|
||||
db := e.Orm.Debug().First(&model, c.GetId())
|
||||
if db.RowsAffected == 0 {
|
||||
return errors.New("无权更新该数据")
|
||||
db := e.Orm.Scopes(
|
||||
actions.Permission(model.TableName(), p),
|
||||
).First(&model, c.GetId())
|
||||
if err := db.Error; err != nil {
|
||||
// First reports a row the data permission excluded exactly as it
|
||||
// reports one that does not exist, and the caller should not be able
|
||||
// to tell those apart either.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("无权更新该数据")
|
||||
}
|
||||
e.Log.Errorf("Service UpdateSysApi error:%s", err)
|
||||
return err
|
||||
}
|
||||
c.Generate(&model)
|
||||
db = e.Orm.Save(&model)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
// An update the data permission excludes has to be refused, and refused in a
|
||||
// way that does not tell the caller whether the row exists. First reports both
|
||||
// cases the same way - no rows - so the message has to come from there rather
|
||||
// than from a RowsAffected check the error return has already skipped past.
|
||||
func TestSysApiUpdateRefusesARowOutsideTheDataPermission(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:sysapi-perm?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("sqlite unavailable: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysApi{}); err != nil {
|
||||
t.Skipf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
prev := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = true
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = prev })
|
||||
|
||||
// Owned by user 1.
|
||||
row := models.SysApi{Handle: "h", Title: "t", Path: "/api/v1/probe", Type: "BUS", Action: "GET"}
|
||||
row.CreateBy = 1
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e := &SysApi{Service: service.Service{Orm: db, Log: logger.NewHelper(logger.DefaultLogger)}}
|
||||
req := &dto.SysApiUpdateReq{Id: row.Id, Title: "changed"}
|
||||
|
||||
// User 2, scope 5: only rows they created.
|
||||
outsider := &actions.DataPermission{DataScope: "5", UserId: 2, DeptId: 1, RoleId: 2}
|
||||
err = e.Update(req, outsider)
|
||||
if err == nil {
|
||||
t.Fatal("the update was allowed on a row the data permission excludes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "无权更新该数据") {
|
||||
t.Errorf("refused with %q, want the permission message; a raw database error tells the "+
|
||||
"caller the row exists", err)
|
||||
}
|
||||
|
||||
var after models.SysApi
|
||||
if err := db.First(&after, row.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Title != "t" {
|
||||
t.Errorf("the row was modified: title is now %q", after.Title)
|
||||
}
|
||||
|
||||
// The owner still gets through, so the scope is refusing rather than
|
||||
// everything failing.
|
||||
owner := &actions.DataPermission{DataScope: "5", UserId: 1, DeptId: 1, RoleId: 1}
|
||||
if err := e.Update(&dto.SysApiUpdateReq{Id: row.Id, Title: "by owner"}, owner); err != nil {
|
||||
t.Fatalf("the owner could not update their own row: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -107,4 +107,4 @@ type SysRoleMenu struct {
|
||||
// return nil, err
|
||||
// }
|
||||
// return r, nil
|
||||
//}
|
||||
//}
|
||||
|
||||
@@ -38,6 +38,30 @@ func (e *SysUser) GetPage(c *dto.SysUserGetPageReq, p *actions.DataPermission, l
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSelf 获取调用者自己的 SysUser 对象,不套数据权限
|
||||
//
|
||||
// The data scope answers "whose rows may this user see"; the caller here is
|
||||
// reading their own, and the id comes from the token, so there is nothing left
|
||||
// for a scope to restrict. Applying one is not a stricter version of this
|
||||
// query - it is a broken one. DataScopeSelf matches on create_by, and a user
|
||||
// account is created by whoever added it, so a scoped self-read would fail for
|
||||
// every user who did not create their own account.
|
||||
//
|
||||
// GetProfile has always read the same row this way, with no scope at all.
|
||||
func (e *SysUser) GetSelf(d *dto.SysUserById, model *models.SysUser) error {
|
||||
err := e.Orm.First(model, d.GetId()).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = errors.New("查看对象不存在或无权查看")
|
||||
e.Log.Errorf("db error: %s", err)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
e.Log.Errorf("db error: %s", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取SysUser对象
|
||||
func (e *SysUser) Get(d *dto.SysUserById, p *actions.DataPermission, model *models.SysUser) error {
|
||||
var data models.SysUser
|
||||
@@ -84,7 +108,16 @@ func (e *SysUser) Insert(c *dto.SysUserInsertReq) error {
|
||||
}
|
||||
|
||||
// Update 修改SysUser对象
|
||||
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) error {
|
||||
//
|
||||
// callerId is who is asking, not who SetUpdateBy recorded - that field only
|
||||
// says who to blame, it never constrained who could be edited. When the
|
||||
// target is the caller themselves, roleId/deptId/status are kept at whatever
|
||||
// the database already has no matter what the request body carries: this is
|
||||
// the personal-center screen's route (see CasbinExclude in settings.go, and
|
||||
// the check in the API handler ahead of this call), and letting a caller
|
||||
// grant themselves a different role or department through it would be a
|
||||
// privilege escalation the exclusion was never meant to open.
|
||||
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission, callerId int) error {
|
||||
var err error
|
||||
var model models.SysUser
|
||||
db := e.Orm.Scopes(
|
||||
@@ -98,6 +131,11 @@ func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) err
|
||||
return errors.New("无权更新该数据")
|
||||
|
||||
}
|
||||
if model.UserId == callerId {
|
||||
c.RoleId = model.RoleId
|
||||
c.DeptId = model.DeptId
|
||||
c.Status = model.Status
|
||||
}
|
||||
c.Generate(&model)
|
||||
update := e.Orm.Model(&model).Where("user_id = ?", &model.UserId).Omit("password", "salt").Updates(&model)
|
||||
if err = update.Error; err != nil {
|
||||
|
||||
@@ -33,11 +33,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
InitBusinessRouter(r, authMiddleware)
|
||||
|
||||
+30
-4
@@ -1,6 +1,7 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
@@ -145,11 +146,36 @@ func setup(key string, db *gorm.DB) {
|
||||
}
|
||||
|
||||
// 其中任务
|
||||
crontab.Start()
|
||||
startCrontab(crontab)
|
||||
}
|
||||
|
||||
// startCrontab starts c and arranges for it to be stopped on the way out.
|
||||
//
|
||||
// The stop used to be `defer crontab.Stop()` followed by `select {}`. The
|
||||
// select never returned, so the defer never ran and the scheduler was never
|
||||
// stopped; and because setup never returned, the loop in Setup never reached
|
||||
// the second tenant - only whichever database came first out of the map ever
|
||||
// got a scheduler at all. cron.Start is itself `go c.run()`, so the select was
|
||||
// blocking for nothing.
|
||||
//
|
||||
// cron.Stop returns a context that closes once the jobs already running have
|
||||
// finished. That is the wait the shutdown budget exists to bound: giving up on
|
||||
// it leaves those jobs running until the process exits, which is better than
|
||||
// holding the whole shutdown open for one job that will not end.
|
||||
func startCrontab(c *cron.Cron) {
|
||||
c.Start()
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
|
||||
|
||||
// 关闭任务
|
||||
defer crontab.Stop()
|
||||
select {}
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
stopped := c.Stop()
|
||||
select {
|
||||
case <-stopped.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
|
||||
case <-ctx.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AddJob 添加任务 AddJob(invokeTarget string, jobId int, jobName string, cronExpression string)
|
||||
@@ -183,7 +209,7 @@ func (e *ExecJob) addJob(c *cron.Cron) (int, error) {
|
||||
|
||||
// Remove 移除任务
|
||||
func Remove(c *cron.Cron, entryID int) chan bool {
|
||||
ch := make(chan bool)
|
||||
ch := make(chan bool, 1)
|
||||
go func() {
|
||||
c.Remove(cron.EntryID(entryID))
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Remove success ,info entryID :", entryID)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
)
|
||||
|
||||
// The scheduler had never been stopped. `defer crontab.Stop()` sat directly
|
||||
// above a `select {}` that never returned, so the deferred call was
|
||||
// unreachable for the life of the process.
|
||||
//
|
||||
// There is one test rather than several because BeforeExit closes to further
|
||||
// registration once it has run: a second RunShutdown in this binary would find
|
||||
// an empty registry and pass while proving nothing.
|
||||
func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
|
||||
var ticks atomic.Int64
|
||||
|
||||
c := cronjob.NewWithSeconds()
|
||||
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
|
||||
t.Fatalf("AddFunc: %v", err)
|
||||
}
|
||||
|
||||
startCrontab(c)
|
||||
|
||||
// It has to be running before stopping it can mean anything.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for ticks.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if ticks.Load() == 0 {
|
||||
t.Fatal("the scheduler never ran the job, so this test cannot show it was stopped")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := sdk.Runtime.RunShutdown(ctx); err != nil {
|
||||
t.Fatalf("RunShutdown: %v", err)
|
||||
}
|
||||
|
||||
// Two and a half seconds is two more firings of a job that runs every
|
||||
// second, so silence here is the assertion.
|
||||
at := ticks.Load()
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
if n := ticks.Load() - at; n > 0 {
|
||||
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
initRouter(r, authMiddleware)
|
||||
|
||||
@@ -39,7 +39,7 @@ func (e *SysJob) RemoveJob(c *dto.GeneralDelDto) error {
|
||||
}
|
||||
case <-time.After(time.Second * 1):
|
||||
e.Msg = "操作超时!"
|
||||
return nil
|
||||
return errors.New(e.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
coreservice "github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/jobs/models"
|
||||
"go-admin/common/dto"
|
||||
)
|
||||
|
||||
type blockedSchedule struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (s blockedSchedule) Next(now time.Time) time.Time {
|
||||
close(s.started)
|
||||
<-s.release
|
||||
return now.Add(time.Hour)
|
||||
}
|
||||
|
||||
func TestRemoveJob(t *testing.T) {
|
||||
for _, blocked := range []bool{false, true} {
|
||||
name := "success"
|
||||
if blocked {
|
||||
name = "timeout"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&models.SysJob{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := cron.New()
|
||||
schedule := blockedSchedule{make(chan struct{}), make(chan struct{})}
|
||||
entryID := c.Schedule(schedule, cron.FuncJob(func() {}))
|
||||
if blocked {
|
||||
c.Start()
|
||||
t.Cleanup(func() {
|
||||
close(schedule.release)
|
||||
<-c.Stop().Done()
|
||||
})
|
||||
select {
|
||||
case <-schedule.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("scheduler did not start")
|
||||
}
|
||||
}
|
||||
job := models.SysJob{EntryId: int(entryID)}
|
||||
if err := db.Create(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := SysJob{Service: coreservice.Service{Orm: db}, Cron: c}
|
||||
err = s.RemoveJob(&dto.GeneralDelDto{Id: job.JobId})
|
||||
if blocked {
|
||||
if err == nil || err.Error() != "操作超时!" {
|
||||
t.Errorf("RemoveJob error = %v, want timeout error", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved models.SysJob
|
||||
if err := db.First(&saved, job.JobId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEntryID := 0
|
||||
if blocked {
|
||||
wantEntryID = int(entryID)
|
||||
}
|
||||
if saved.EntryId != wantEntryID {
|
||||
t.Errorf("entry_id = %d, want %d", saved.EntryId, wantEntryID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import (
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// emptyTableNameMsg is what the generator's endpoints answer with when the
|
||||
// request named no table. Declared once because the tests assert on it: spelled
|
||||
// out again at each site, a reworded message would leave them asserting on a
|
||||
// string the server no longer sends, and still passing.
|
||||
const emptyTableNameMsg = "table name cannot be empty!"
|
||||
|
||||
// GetDBColumnList 分页列表数据
|
||||
// @Summary 分页列表数据 / page list data
|
||||
// @Description 数据库表列分页列表 / database table column page list
|
||||
@@ -41,7 +47,7 @@ func (e Gen) GetDBColumnList(c *gin.Context) {
|
||||
}
|
||||
|
||||
data.TableName = c.Request.FormValue("tableName")
|
||||
pkg.Assert(data.TableName != "", "table name cannot be empty!", 500)
|
||||
pkg.Assert(data.TableName != "", emptyTableNameMsg, 500)
|
||||
result, count, err := data.GetPage(db, pageSize, pageIndex)
|
||||
if err != nil {
|
||||
log.Errorf("GetPage error, %s", err.Error())
|
||||
|
||||
@@ -16,17 +16,21 @@ import (
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
const emptyTableNameMsg = "table name cannot be empty!"
|
||||
|
||||
// bodyOf covers both the success and the CustomError shape: both carry msg.
|
||||
type bodyOf struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// newColumnListEngine wires the handler the way the router does, including the
|
||||
// newEngine wires one generator handler the way the router does, including the
|
||||
// middleware that turns pkg.Assert's panic into a response.
|
||||
func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
//
|
||||
// The generator's queries target MySQL's information_schema and cannot run on
|
||||
// the sqlite connection behind them; the driver setting only has to select that
|
||||
// branch, since no statement here is expected to succeed. That makes this
|
||||
// serviceable for any handler in this package whose behaviour is decided before
|
||||
// the query goes out -- which is what these tests are about.
|
||||
func newEngine(t *testing.T, method, path string, h gin.HandlerFunc) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -35,26 +39,33 @@ func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
// The query targets MySQL's information_schema; the driver setting only has
|
||||
// to select that branch, the statement itself is never expected to succeed.
|
||||
previous := config.DatabaseConfig.Driver
|
||||
config.DatabaseConfig.Driver = "mysql"
|
||||
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CustomError)
|
||||
r.GET("/db/columns/page", func(c *gin.Context) {
|
||||
r.Handle(method, path, func(c *gin.Context) {
|
||||
c.Set("db", db)
|
||||
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
|
||||
Gen{}.GetDBColumnList(c)
|
||||
h(c)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
return newEngine(t, http.MethodGet, "/db/columns/page", Gen{}.GetDBColumnList)
|
||||
}
|
||||
|
||||
// serveJSON runs one request through the engine and decodes the envelope every
|
||||
// handler here answers with. A body that will not decode fails the test rather
|
||||
// than being reported as a mismatched message, which reads as the handler
|
||||
// having answered something unexpected instead of not having answered at all.
|
||||
func serveJSON(t *testing.T, r *gin.Engine, req *http.Request) bodyOf {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var body bodyOf
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
@@ -63,6 +74,11 @@ func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
return body
|
||||
}
|
||||
|
||||
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
t.Helper()
|
||||
return serveJSON(t, r, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
|
||||
}
|
||||
|
||||
func TestGetDBColumnList_AcceptsATableName(t *testing.T) {
|
||||
body := columnListMsg(t, newColumnListEngine(t), "?tableName=sys_user")
|
||||
if body.Msg == emptyTableNameMsg {
|
||||
|
||||
@@ -83,7 +83,7 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
var b1 bytes.Buffer
|
||||
err = t1.Execute(&b1, tab)
|
||||
var b2 bytes.Buffer
|
||||
@@ -129,7 +129,7 @@ func (e Gen) GenCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
|
||||
e.NOActionsGen(c, tab)
|
||||
|
||||
@@ -155,7 +155,7 @@ func (e Gen) GenApiToFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
e.genApiToFile(c, tab)
|
||||
|
||||
e.OK("", "Code generated successfully!")
|
||||
@@ -302,7 +302,7 @@ func (e Gen) GenMenuAndApi(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(e.Orm,true)
|
||||
tab, _ := table.Get(e.Orm, true)
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
|
||||
Mmenu := dto.SysMenuInsertReq{}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
@@ -79,7 +80,7 @@ func (e SysTable) Get(c *gin.Context) {
|
||||
|
||||
var data tools.SysTables
|
||||
data.TableId, _ = pkg.StringToInt(c.Param("tableId"))
|
||||
result, err := data.Get(db,true)
|
||||
result, err := data.Get(db, true)
|
||||
if err != nil {
|
||||
log.Errorf("Get error, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
@@ -106,7 +107,7 @@ func (e SysTable) GetSysTablesInfo(c *gin.Context) {
|
||||
if c.Request.FormValue("tableName") != "" {
|
||||
data.TBName = c.Request.FormValue("tableName")
|
||||
}
|
||||
result, err := data.Get(db,true)
|
||||
result, err := data.Get(db, true)
|
||||
if err != nil {
|
||||
log.Errorf("Get error, %s", err.Error())
|
||||
e.Error(500, err, "抱歉未找到相关信息")
|
||||
@@ -148,7 +149,8 @@ func (e SysTable) GetSysTablesTree(c *gin.Context) {
|
||||
// @Tags 工具 / 生成工具
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param tables query string false "tableName / 数据表名称"
|
||||
// @Param tables query string false "tableName / 数据表名称,逗号分隔"
|
||||
// @Param data body object false "tables / 同上,query 未带时从 JSON body 读"
|
||||
// @Success 200 {string} string "{"code": 200, "message": "添加成功"}"
|
||||
// @Success 200 {string} string "{"code": -1, "message": "添加失败"}"
|
||||
// @Router /api/v1/sys/tables/info [post]
|
||||
@@ -163,7 +165,13 @@ func (e SysTable) Insert(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tablesList := strings.Split(c.Request.FormValue("tables"), ",")
|
||||
tablesList, err := tablesToImport(c)
|
||||
if err != nil {
|
||||
log.Errorf("read the table list, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(tablesList); i++ {
|
||||
|
||||
data, err := genTableInit(db, tablesList, i, c)
|
||||
@@ -184,6 +192,45 @@ func (e SysTable) Insert(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
// tablesToImport reads the comma-separated table list carried by an import
|
||||
// request, from the query string or from a JSON body.
|
||||
//
|
||||
// The list has only ever travelled in the query string, which is the single
|
||||
// place FormValue looks once the request declares itself as JSON. A front end
|
||||
// that puts it in the body instead therefore left this empty, and the import
|
||||
// went on to ask information_schema for a table named "" -- go-admin-ui v3.2.0
|
||||
// shipped exactly that, and every import failed with the message below.
|
||||
// Reading the body when the query has nothing keeps either front end working.
|
||||
func tablesToImport(c *gin.Context) ([]string, error) {
|
||||
raw := c.Request.FormValue("tables")
|
||||
if raw == "" {
|
||||
var body struct {
|
||||
Tables string `json:"tables"`
|
||||
}
|
||||
// A body that is absent, or shaped some other way, is not itself worth
|
||||
// reporting: the list is missing either way, and the message below says
|
||||
// so in the terms the caller asked in.
|
||||
if err := c.ShouldBindJSON(&body); err == nil {
|
||||
raw = body.Tables
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
names := make([]string, 0, len(parts))
|
||||
for _, name := range parts {
|
||||
// Splitting "" yields one empty name rather than nothing at all, so
|
||||
// without this an empty list reads as a request to import one table
|
||||
// whose name happens to be blank.
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, errors.New(emptyTableNameMsg)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func genTableInit(tx *gorm.DB, tablesList []string, i int, c *gin.Context) (tools.SysTables, error) {
|
||||
var data tools.SysTables
|
||||
var dbTable tools.DBTables
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newImportRequest builds the request an import arrives in. Where the table
|
||||
// list sits -- query or body -- is exactly what these tests are about, and it
|
||||
// is net/http's form parsing that decides what a handler can reach, so these go
|
||||
// through a real *http.Request rather than a hand-built one.
|
||||
func newImportRequest(target, contentType, body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestTablesToImport(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
contentType string
|
||||
body string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{{
|
||||
name: "from the query, as every front end before v3.2.0 sent it",
|
||||
target: "/sys/tables/info?tables=sys_user,sys_post",
|
||||
want: []string{"sys_user", "sys_post"},
|
||||
}, {
|
||||
name: "from a JSON body, as go-admin-ui v3.2.0 sends it",
|
||||
target: "/sys/tables/info",
|
||||
contentType: "application/json",
|
||||
body: `{"tables":"sys_user,sys_post"}`,
|
||||
want: []string{"sys_user", "sys_post"},
|
||||
}, {
|
||||
name: "the query wins when a request carries both",
|
||||
target: "/sys/tables/info?tables=sys_user",
|
||||
contentType: "application/json",
|
||||
body: `{"tables":"sys_post"}`,
|
||||
want: []string{"sys_user"},
|
||||
}, {
|
||||
name: "blank entries are dropped rather than imported as a nameless table",
|
||||
target: "/sys/tables/info?tables=sys_user,,%20,sys_post",
|
||||
want: []string{"sys_user", "sys_post"},
|
||||
}, {
|
||||
name: "a body carrying an empty list is an error",
|
||||
target: "/sys/tables/info",
|
||||
contentType: "application/json",
|
||||
body: `{"tables":""}`,
|
||||
wantErr: true,
|
||||
}, {
|
||||
name: "a body that is not JSON at all is an error, not a panic",
|
||||
target: "/sys/tables/info",
|
||||
contentType: "application/json",
|
||||
body: "sys_user",
|
||||
wantErr: true,
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = newImportRequest(tc.target, tc.contentType, tc.body)
|
||||
|
||||
got, err := tablesToImport(c)
|
||||
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got %q", got)
|
||||
}
|
||||
if err.Error() != emptyTableNameMsg {
|
||||
t.Fatalf("message should be the one the front end shows, got %q", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// insertMsg runs one import through the wired handler. It asserts nothing about
|
||||
// the import succeeding -- it cannot, over sqlite -- only about how far the
|
||||
// request got, which the empty-list message is what distinguishes.
|
||||
func insertMsg(t *testing.T, target, contentType, body string) bodyOf {
|
||||
t.Helper()
|
||||
return serveJSON(t,
|
||||
newEngine(t, http.MethodPost, "/sys/tables/info", SysTable{}.Insert),
|
||||
newImportRequest(target, contentType, body))
|
||||
}
|
||||
|
||||
func TestInsert_ReadsTheTableListFromEitherPlace(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target string
|
||||
contentType string
|
||||
body string
|
||||
}{
|
||||
{"query", "/sys/tables/info?tables=sys_user", "", ""},
|
||||
{"JSON body", "/sys/tables/info", "application/json", `{"tables":"sys_user"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := insertMsg(t, tc.target, tc.contentType, tc.body); got.Msg == emptyTableNameMsg {
|
||||
t.Fatalf("request carried a table name and was still rejected as empty: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsert_RejectsAMissingTableList(t *testing.T) {
|
||||
if got := insertMsg(t, "/sys/tables/info", "", ""); got.Msg != emptyTableNameMsg {
|
||||
t.Fatalf("missing table list should be rejected, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
// registeredRoutes builds the generator's routes on an engine of its own and
|
||||
// reports the patterns they were registered under.
|
||||
//
|
||||
// The mode has to be given rather than inherited, because it now decides what
|
||||
// gets registered: a test that leaves it at the zero value would be asking
|
||||
// about a mode no deployment runs in, and would pass whether or not the gate
|
||||
// works.
|
||||
//
|
||||
// It is put back before this returns, not at the end of the test. t.Cleanup
|
||||
// would leave the mode set for everything the caller does afterwards, so a
|
||||
// caller that went on to assert something mode-dependent would be reading a
|
||||
// value this helper left behind rather than one it chose. A caller that does
|
||||
// want the mode set has to set it, which is visible where it happens.
|
||||
//
|
||||
// The JWT middleware is a zero value. MiddlewareFunc only closes over the
|
||||
// receiver and is never called here - no request is served, the engine is
|
||||
// asked what it has - so nothing dereferences it.
|
||||
func registeredRoutes(t *testing.T, mode string) map[string]bool {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
previous := config.ApplicationConfig.Mode
|
||||
defer func() { config.ApplicationConfig.Mode = previous }()
|
||||
config.ApplicationConfig.Mode = mode
|
||||
|
||||
r := gin.New()
|
||||
v1 := r.Group("/api/v1")
|
||||
sysNoCheckRoleRouter(v1, &jwt.GinJWTMiddleware{})
|
||||
registerDBRouter(v1, &jwt.GinJWTMiddleware{})
|
||||
|
||||
out := map[string]bool{}
|
||||
for _, route := range r.Routes() {
|
||||
out[route.Path] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The list of routes demo mode refuses lives in common/middleware, which may
|
||||
// not import app/ and therefore cannot see whether any of them is still a
|
||||
// route. This is the half that can be checked, and it is checked here because
|
||||
// this is where the routes are declared: rename one, and the entry over there
|
||||
// stops matching anything, demo mode silently starts serving it again, and
|
||||
// nothing else would say so.
|
||||
func TestEveryRouteDemoModeRefusesStillExists(t *testing.T) {
|
||||
routes := registeredRoutes(t, "demo")
|
||||
for _, guarded := range middleware.DemoWriteRoutes() {
|
||||
if !routes[guarded] {
|
||||
t.Errorf("demo mode refuses %q, but no route is registered under that pattern - "+
|
||||
"either it was renamed, or it moved to another file; the guard now matches nothing",
|
||||
guarded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other direction, and the one the demo host cares about: the generator's
|
||||
// read-only routes have to stay reachable, or a demo deployment cannot show
|
||||
// the feature at all. Refusing too much is as much of a defect as refusing too
|
||||
// little.
|
||||
func TestTheGeneratorsReadOnlyRoutesAreNotRefused(t *testing.T) {
|
||||
refused := map[string]bool{}
|
||||
for _, guarded := range middleware.DemoWriteRoutes() {
|
||||
refused[guarded] = true
|
||||
}
|
||||
|
||||
for _, readOnly := range []string{
|
||||
"/api/v1/gen/preview/:tableId",
|
||||
"/api/v1/gen/tabletree",
|
||||
"/api/v1/db/tables/page",
|
||||
"/api/v1/db/columns/page",
|
||||
} {
|
||||
if !registeredRoutes(t, "demo")[readOnly] {
|
||||
t.Fatalf("%s is not registered, so this test is asserting against nothing", readOnly)
|
||||
}
|
||||
if refused[readOnly] {
|
||||
t.Errorf("demo mode refuses %s, which only reads - the demo host needs it to "+
|
||||
"demonstrate the generator", readOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,49 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/app/other/apis/tools"
|
||||
)
|
||||
|
||||
// GenWriteRoutesEnabled reports whether the code generator's writing endpoints
|
||||
// are registered in this process.
|
||||
//
|
||||
// Three of the generator's endpoints do not read. /gen/toproject writes seven
|
||||
// Go and Vue source files onto this host, one of them under the path
|
||||
// gen.frontpath names; /gen/apitofile writes a migration; /gen/todb inserts
|
||||
// menus and APIs. All three are GET, all three are listed in CasbinExclude,
|
||||
// and AuthCheckRole skips what is on that list - so Enforce never runs for
|
||||
// them and any account that can log in may call them. That is a bargain a
|
||||
// workstation can make and a deployment cannot.
|
||||
//
|
||||
// dev is the shipped default and is where the generator is meant to be used.
|
||||
// demo keeps them because demo mode already has a better answer than a 404:
|
||||
// DemoEvn refuses these three by name and explains itself, which is the thing
|
||||
// the demo exists to show. test and prod get nothing.
|
||||
//
|
||||
// The mode is read once, while the routes are being built. Changing
|
||||
// application.mode in a running process adds and removes nothing - a
|
||||
// configuration reload rebuilds neither the engine nor its routes.
|
||||
//
|
||||
// core has constants for dev, test and prod but none for demo, which this
|
||||
// repository spells as a literal in common/middleware/demo.go. Both are
|
||||
// literals here so that the two read as one set rather than two conventions.
|
||||
func GenWriteRoutesEnabled() bool {
|
||||
switch config.ApplicationConfig.Mode {
|
||||
case "dev", "demo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, sysNoCheckRoleRouter, registerDBRouter, registerSysTableRouter)
|
||||
}
|
||||
|
||||
func sysNoCheckRoleRouter(v1 *gin.RouterGroup ,authMiddleware *jwt.GinJWTMiddleware) {
|
||||
func sysNoCheckRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
r1 := v1.Group("")
|
||||
{
|
||||
sys := apis.System{}
|
||||
@@ -22,9 +56,11 @@ func sysNoCheckRoleRouter(v1 *gin.RouterGroup ,authMiddleware *jwt.GinJWTMiddlew
|
||||
{
|
||||
gen := tools.Gen{}
|
||||
r.GET("/gen/preview/:tableId", gen.Preview)
|
||||
r.GET("/gen/toproject/:tableId", gen.GenCode)
|
||||
r.GET("/gen/apitofile/:tableId", gen.GenApiToFile)
|
||||
r.GET("/gen/todb/:tableId", gen.GenMenuAndApi)
|
||||
if GenWriteRoutesEnabled() {
|
||||
r.GET("/gen/toproject/:tableId", gen.GenCode)
|
||||
r.GET("/gen/apitofile/:tableId", gen.GenApiToFile)
|
||||
r.GET("/gen/todb/:tableId", gen.GenMenuAndApi)
|
||||
}
|
||||
sysTable := tools.SysTable{}
|
||||
r.GET("/gen/tabletree", sysTable.GetSysTablesTree)
|
||||
}
|
||||
@@ -53,4 +89,4 @@ func registerSysTableRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddl
|
||||
tablesInfo.GET("", sysTable.GetSysTablesInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
// genWritingRoutes are the three that do not read. They write Go and Vue
|
||||
// source onto the host, a migration, and rows in sys_menu.
|
||||
var genWritingRoutes = []string{
|
||||
"/api/v1/gen/toproject/:tableId",
|
||||
"/api/v1/gen/apitofile/:tableId",
|
||||
"/api/v1/gen/todb/:tableId",
|
||||
}
|
||||
|
||||
// genReadingRoutes are the rest of the generator's surface. Gating the three
|
||||
// above must not cost any of these: a deployment that cannot list its tables
|
||||
// or preview a template has lost the feature, not secured it.
|
||||
var genReadingRoutes = []string{
|
||||
"/api/v1/gen/preview/:tableId",
|
||||
"/api/v1/gen/tabletree",
|
||||
"/api/v1/db/tables/page",
|
||||
"/api/v1/db/columns/page",
|
||||
}
|
||||
|
||||
// The endpoints that write are registered where the mode says development and
|
||||
// nowhere else.
|
||||
//
|
||||
// They are in CasbinExclude, so Enforce never runs for them and any account
|
||||
// that can log in may call them. dev is the shipped default and is where the
|
||||
// generator is meant to be used; demo keeps them because DemoEvn refuses these
|
||||
// three by name and saying so is the thing the demo is for. Everything else,
|
||||
// including the empty mode a process gets when nothing set one, is refused by
|
||||
// not existing.
|
||||
func TestGeneratorWritingRoutesExistOnlyWhereTheModeAllowsIt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
expected bool
|
||||
why string
|
||||
}{
|
||||
{"dev", true, "the shipped default, and where the generator is used"},
|
||||
{"demo", true, "registered so demo mode can refuse them by name"},
|
||||
{"test", false, "a deployment, however much it is called a test"},
|
||||
{"prod", false, "a deployment"},
|
||||
{"", false, "no mode configured is not a reason to trust the caller"},
|
||||
} {
|
||||
t.Run("mode="+tc.mode, func(t *testing.T) {
|
||||
routes := registeredRoutes(t, tc.mode)
|
||||
for _, writing := range genWritingRoutes {
|
||||
if got := routes[writing]; got != tc.expected {
|
||||
t.Errorf("mode %q: %s registered = %v, want %v (%s)",
|
||||
tc.mode, writing, got, tc.expected, tc.why)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The other direction. Refusing too much is as much of a defect as refusing
|
||||
// too little, and the read-only half of the generator is what a demo shows.
|
||||
func TestGeneratorReadingRoutesExistInEveryMode(t *testing.T) {
|
||||
for _, mode := range []string{"dev", "demo", "test", "prod", ""} {
|
||||
t.Run("mode="+mode, func(t *testing.T) {
|
||||
routes := registeredRoutes(t, mode)
|
||||
for _, reading := range genReadingRoutes {
|
||||
if !routes[reading] {
|
||||
t.Errorf("mode %q: %s is not registered - the gate took a route that only reads",
|
||||
mode, reading)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GenWriteRoutesEnabled is what cmd/api reads to decide whether to warn at
|
||||
// start-up. If it and the registration ever disagree, the log says one thing
|
||||
// and the engine does another, so they are checked against each other rather
|
||||
// than each against a list.
|
||||
func TestGenWriteRoutesEnabledAgreesWithWhatWasRegistered(t *testing.T) {
|
||||
for _, mode := range []string{"dev", "demo", "test", "prod", ""} {
|
||||
t.Run("mode="+mode, func(t *testing.T) {
|
||||
routes := registeredRoutes(t, mode)
|
||||
|
||||
// registeredRoutes puts the mode back before it returns, so ask
|
||||
// the predicate under a mode set here - about the same value the
|
||||
// engine was just built under.
|
||||
previous := config.ApplicationConfig.Mode
|
||||
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
|
||||
config.ApplicationConfig.Mode = mode
|
||||
|
||||
claimed := GenWriteRoutesEnabled()
|
||||
actual := routes["/api/v1/gen/todb/:tableId"]
|
||||
if claimed != actual {
|
||||
t.Errorf("mode %q: GenWriteRoutesEnabled() = %v but the route was registered = %v",
|
||||
mode, claimed, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The helper restores the mode before it returns, so nothing it was asked
|
||||
// about leaks into what the caller does next.
|
||||
//
|
||||
// Worth a test of its own because the failure is silent: a helper that left
|
||||
// the mode set would make every assertion after the call read a value the
|
||||
// caller did not choose, and each of those assertions would still pass for as
|
||||
// long as the leaked value happened to be the right one.
|
||||
func TestRegisteredRoutesRestoresTheModeBeforeReturning(t *testing.T) {
|
||||
const sentinel = "not-a-mode"
|
||||
|
||||
previous := config.ApplicationConfig.Mode
|
||||
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
|
||||
config.ApplicationConfig.Mode = sentinel
|
||||
|
||||
registeredRoutes(t, "prod")
|
||||
|
||||
if got := config.ApplicationConfig.Mode; got != sentinel {
|
||||
t.Errorf("mode after the helper returned = %q, want %q - it was left set to what "+
|
||||
"the helper was asked about", got, sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// Changing the mode after the routes were built unregisters nothing.
|
||||
//
|
||||
// buildRouter has one call site, in run(), and route registration is not on
|
||||
// any phase or reload callback - so a configuration reload moves
|
||||
// config.ApplicationConfig.Mode without moving the routes. From that moment
|
||||
// GenWriteRoutesEnabled answers about a mode the engine was not built under.
|
||||
//
|
||||
// That gap is why the start-up warning tells the reader to restart rather than
|
||||
// only to change the mode. This pins it: if registration ever becomes dynamic,
|
||||
// this test fails and the message it justifies has to be revisited.
|
||||
func TestChangingTheModeDoesNotUnregisterWhatWasAlreadyBuilt(t *testing.T) {
|
||||
built := registeredRoutes(t, "dev")
|
||||
if !built["/api/v1/gen/todb/:tableId"] {
|
||||
t.Fatal("built under dev without the writing routes, so this test asserts nothing")
|
||||
}
|
||||
|
||||
previous := config.ApplicationConfig.Mode
|
||||
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
|
||||
config.ApplicationConfig.Mode = "prod"
|
||||
|
||||
if GenWriteRoutesEnabled() {
|
||||
t.Fatal("the predicate still allows prod, so the disagreement below is not the one meant")
|
||||
}
|
||||
if !built["/api/v1/gen/todb/:tableId"] {
|
||||
t.Error("the route left the engine when the mode changed - registration has become " +
|
||||
"dynamic, and the start-up warning's advice to restart is now wrong")
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
// TODO: 这里可存放业务路由,里边并无实际路由只有演示代码
|
||||
|
||||
@@ -1,23 +1,82 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/tools/transfer"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"go-admin/common/health"
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerNoCheckRole = append(routerNoCheckRole, registerMonitorRouter)
|
||||
routerNoCheckRole = append(routerNoCheckRole, RegisterMonitorRouter)
|
||||
}
|
||||
|
||||
// 需认证的路由代码
|
||||
func registerMonitorRouter(v1 *gin.RouterGroup) {
|
||||
// readyTimeout bounds the whole probe. What constrains it is the orchestrator's
|
||||
// per-check timeout rather than its polling period: Kubernetes allows a probe
|
||||
// one second by default, so a dependency that answers in 1.2s is recorded as a
|
||||
// failed check however promptly this handler returns. A manifest that mounts
|
||||
// this probe has to raise timeoutSeconds above this value, and
|
||||
// scripts/k8s/deploy.yml does.
|
||||
const readyTimeout = 2 * time.Second
|
||||
|
||||
// HealthPath and ReadyPath are the two probe routes, relative to APIPrefix.
|
||||
//
|
||||
// Exported for the same reason as the prefix: the rate limiter has to be told
|
||||
// to skip them, and it is installed in a package that cannot import this one.
|
||||
const (
|
||||
HealthPath = "/health"
|
||||
ReadyPath = "/ready"
|
||||
)
|
||||
|
||||
// RegisterMonitorRouter mounts the metrics endpoint and the two probes on v1.
|
||||
//
|
||||
// Exported so that a test can put the real probes on a server of its own. The
|
||||
// alternative - a test that re-implements the handler it means to check - is
|
||||
// how a probe comes to be asserted against a copy of itself.
|
||||
//
|
||||
// 无需认证的路由代码
|
||||
func RegisterMonitorRouter(v1 *gin.RouterGroup) {
|
||||
v1.GET("/metrics", transfer.Handler(promhttp.Handler()))
|
||||
//健康检查
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
|
||||
// 健康检查(存活)
|
||||
//
|
||||
// Stays a bare 200 on purpose. This is the answer to "should I restart
|
||||
// you", and a process whose database is unreachable does not want
|
||||
// restarting - that turns one outage into a crash loop and throws away the
|
||||
// connection pool, the cache and every in-flight request along the way.
|
||||
v1.GET(HealthPath, func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
}
|
||||
// 就绪检查
|
||||
//
|
||||
// The answer to "should I send you requests". It fails while a dependency
|
||||
// is unreachable, and from the moment shutdown begins - for as long as
|
||||
// extend.shutdown.drain says, which is zero unless it is configured. The
|
||||
// package comment in common/health says what that window is worth, and to
|
||||
// whom.
|
||||
v1.GET(ReadyPath, func(c *gin.Context) {
|
||||
if health.Draining() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "draining",
|
||||
"checks": []health.Check{},
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), readyTimeout)
|
||||
defer cancel()
|
||||
|
||||
checks := health.Ready(ctx)
|
||||
status := http.StatusOK
|
||||
if !health.Healthy(checks) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"status": http.StatusText(status), "checks": checks})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ var (
|
||||
routerCheckRole = make([]func(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware), 0)
|
||||
)
|
||||
|
||||
// APIPrefix is the group every route below is registered under.
|
||||
//
|
||||
// Exported because the middleware chain in cmd/api has to name two of those
|
||||
// routes in full - the rate limiter is installed on the engine and must skip
|
||||
// the probes - and a prefix spelled in two places is a prefix that drifts.
|
||||
const APIPrefix = "/api/v1"
|
||||
|
||||
// initRouter 路由示例
|
||||
func initRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine {
|
||||
|
||||
@@ -24,7 +31,7 @@ func initRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine
|
||||
// noCheckRoleRouter 无需认证的路由示例
|
||||
func noCheckRoleRouter(r *gin.Engine) {
|
||||
// 可根据业务需求来设置接口版本
|
||||
v1 := r.Group("/api/v1")
|
||||
v1 := r.Group(APIPrefix)
|
||||
|
||||
for _, f := range routerNoCheckRole {
|
||||
f(v1)
|
||||
@@ -34,7 +41,7 @@ func noCheckRoleRouter(r *gin.Engine) {
|
||||
// checkRoleRouter 需要认证的路由示例
|
||||
func checkRoleRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
// 可根据业务需求来设置接口版本
|
||||
v1 := r.Group("/api/v1")
|
||||
v1 := r.Group(APIPrefix)
|
||||
|
||||
for _, f := range routerCheckRole {
|
||||
f(v1, authMiddleware)
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@ import "go-admin/app/demo/router"
|
||||
func init() {
|
||||
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
// The warning fires exactly where the generator's writing endpoints are served
|
||||
// and nothing else refuses them.
|
||||
//
|
||||
// dev is the case the warning exists for: it is the shipped default, so it is
|
||||
// the mode a deployment that changed nothing is running in. demo serves the
|
||||
// routes too, but DemoEvn refuses all three by name, so warning there would
|
||||
// describe an exposure that is not there.
|
||||
func TestGeneratorWriteRoutesWarningFiresWhereTheExposureIs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{"dev", true, "shipped default, endpoints served and not refused"},
|
||||
{"demo", false, "served, but DemoEvn refuses all three"},
|
||||
{"test", false, "not served"},
|
||||
{"prod", false, "not served"},
|
||||
{"", false, "not served"},
|
||||
} {
|
||||
t.Run("mode="+tc.mode, func(t *testing.T) {
|
||||
previous := config.ApplicationConfig.Mode
|
||||
t.Cleanup(func() { config.ApplicationConfig.Mode = previous })
|
||||
config.ApplicationConfig.Mode = tc.mode
|
||||
|
||||
if got := generatorWriteRoutesNeedWarning(); got != tc.want {
|
||||
t.Errorf("mode %q: warning = %v, want %v (%s)", tc.mode, got, tc.want, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// freePort returns a port nothing is listening on. It is inherently a guess -
|
||||
// the port is free when it is handed back and could be taken a moment later -
|
||||
// but every alternative needs the caller to hold the listener, which is the one
|
||||
// thing these tests cannot do.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("probe listen: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
// AfterListen promises a hook that the port is reachable. Both halves of that
|
||||
// are asserted here, and in one test rather than two, because the phase seals
|
||||
// itself once it has run: a second test calling RunPhase again would find a
|
||||
// closed registry and pass while proving nothing.
|
||||
//
|
||||
// The failing bind comes first for the same reason. It must leave the phase
|
||||
// unsealed, which is only visible if nothing has sealed it yet.
|
||||
func TestAfterListenIsAnnouncedOnlyOnceThePortIsBound(t *testing.T) {
|
||||
// The pause makes the "announced synchronously" claim testable: if the
|
||||
// announcement were moved onto a goroutine, startServing would return
|
||||
// while the hook was still sleeping and the count below would be zero.
|
||||
var ran int
|
||||
sdk.Runtime.SetPhase(runtime.AfterListen, func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
ran++
|
||||
})
|
||||
|
||||
// Somebody else already has the port. Under ListenAndServe this surfaced
|
||||
// on the serving goroutine, far too late to stop the announcement.
|
||||
taken, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("occupy: %v", err)
|
||||
}
|
||||
defer func() { _ = taken.Close() }()
|
||||
|
||||
blocked := &http.Server{Addr: taken.Addr().String(), Handler: http.NewServeMux()}
|
||||
if err := startServing(blocked, false, "", ""); err == nil {
|
||||
t.Fatal("startServing returned no error for a port that was already taken")
|
||||
}
|
||||
if ran != 0 {
|
||||
t.Errorf("AfterListen ran %d times after a failed bind; a hook there is told the port is reachable", ran)
|
||||
}
|
||||
if sdk.Runtime.PhaseSealed(runtime.AfterListen) {
|
||||
t.Error("a failed bind sealed AfterListen, so the phase could never run for a server that did start")
|
||||
}
|
||||
|
||||
// A certificate that cannot be read is the other way to fail before there
|
||||
// is anything to announce. ServeTLS reads it on the serving goroutine, so
|
||||
// without the check in startServing this would be a hook told the port was
|
||||
// reachable while the server was already on its way down.
|
||||
if err := startServing(&http.Server{Addr: "127.0.0.1:0"}, true, "no-such.pem", "no-such.key"); err == nil {
|
||||
t.Fatal("startServing returned no error for a certificate that does not exist")
|
||||
}
|
||||
if ran != 0 {
|
||||
t.Errorf("AfterListen ran %d times after a certificate failure", ran)
|
||||
}
|
||||
if sdk.Runtime.PhaseSealed(runtime.AfterListen) {
|
||||
t.Error("a certificate failure sealed AfterListen")
|
||||
}
|
||||
|
||||
// And now a bind that works.
|
||||
port := freePort(t)
|
||||
srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: http.NewServeMux()}
|
||||
if err := startServing(srv, false, "", ""); err != nil {
|
||||
t.Fatalf("startServing on a free port: %v", err)
|
||||
}
|
||||
defer func() { _ = srv.Close() }()
|
||||
|
||||
// Checked the instant startServing returns, so this is also the assertion
|
||||
// that it did not return early: an asynchronous announcement would still
|
||||
// be inside the sleep. Synchrony matters because an announcement that
|
||||
// overlaps the wait below could, on a fast SIGTERM, have the shutdown
|
||||
// callbacks finish before the startup ones.
|
||||
if ran != 1 {
|
||||
t.Fatalf("AfterListen ran %d times, want 1", ran)
|
||||
}
|
||||
|
||||
// The claim is not "Serve was called" but "the port answers". Dial it.
|
||||
c, err := net.DialTimeout("tcp", srv.Addr, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("AfterListen ran but the port does not answer: %v", err)
|
||||
}
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
// BeforeRouter is the last point at which a module can still affect how routes
|
||||
// are built, so it has to run while there is no engine yet. The before registry
|
||||
// is a different moment despite the name: those callbacks run after initRouter
|
||||
// has built the engine.
|
||||
//
|
||||
// The two are two lines apart in buildRouter, and calling them equivalent is a
|
||||
// mistake this repository has already made in writing. Until this test the
|
||||
// ordering was checked by reading - which is how the stop signals came to be
|
||||
// armed after the readiness banner in the same file.
|
||||
func TestBeforeRouterRunsWhileThereIsNoEngine(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
// AuthInit reads these two package-level values and nothing else. No
|
||||
// database is involved in building a router: the handlers are registered,
|
||||
// not called.
|
||||
config.ApplicationConfig.Mode = "dev"
|
||||
config.JwtConfig.Secret = "test-secret-for-the-router-build"
|
||||
|
||||
type observation struct {
|
||||
ran int
|
||||
engineWas interface{}
|
||||
engineSeen bool
|
||||
}
|
||||
var phase, before observation
|
||||
|
||||
sdk.Runtime.SetPhase(runtime.BeforeRouter, func() {
|
||||
phase.ran++
|
||||
phase.engineWas = sdk.Runtime.GetEngine()
|
||||
phase.engineSeen = true
|
||||
})
|
||||
sdk.Runtime.SetBefore(func() {
|
||||
before.ran++
|
||||
before.engineWas = sdk.Runtime.GetEngine()
|
||||
before.engineSeen = true
|
||||
})
|
||||
|
||||
buildRouter()
|
||||
|
||||
if phase.ran != 1 {
|
||||
t.Fatalf("BeforeRouter ran %d times, want 1", phase.ran)
|
||||
}
|
||||
if !phase.engineSeen || phase.engineWas != nil {
|
||||
t.Errorf("BeforeRouter saw engine %v, want nil: it is meant to run before initRouter builds one", phase.engineWas)
|
||||
}
|
||||
|
||||
if before.ran != 1 {
|
||||
t.Fatalf("the before registry ran %d times, want 1", before.ran)
|
||||
}
|
||||
if before.engineWas == nil {
|
||||
t.Error("a before callback saw no engine; that registry is meant to run after initRouter, and describing it as equivalent to BeforeRouter is the error this asserts against")
|
||||
}
|
||||
|
||||
if sdk.Runtime.GetEngine() == nil {
|
||||
t.Error("buildRouter returned with no engine built")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
)
|
||||
|
||||
// recordingQueue records what was done to it, in order. Register and Run are
|
||||
// the two calls whose order is the point of this file; Append and Shutdown are
|
||||
// here to satisfy the interface.
|
||||
type recordingQueue struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
ran chan struct{}
|
||||
}
|
||||
|
||||
func newRecordingQueue() *recordingQueue {
|
||||
return &recordingQueue{ran: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (q *recordingQueue) record(e string) {
|
||||
q.mu.Lock()
|
||||
q.events = append(q.events, e)
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *recordingQueue) seen() []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return append([]string(nil), q.events...)
|
||||
}
|
||||
|
||||
func (q *recordingQueue) String() string { return "recording" }
|
||||
func (q *recordingQueue) Append(corestorage.Messager) error { return nil }
|
||||
func (q *recordingQueue) Register(name string, _ corestorage.ConsumerFunc) {
|
||||
q.record("register:" + name)
|
||||
}
|
||||
func (q *recordingQueue) Shutdown() {}
|
||||
|
||||
func (q *recordingQueue) Run() {
|
||||
q.record("run")
|
||||
select {
|
||||
case q.ran <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRun waits for Run, which is started on a goroutine.
|
||||
func (q *recordingQueue) waitForRun(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-q.ran:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("Run was never called; saw: %v", q.seen())
|
||||
}
|
||||
}
|
||||
|
||||
// The consumers must be registered before the queue is started. A queue that
|
||||
// is already running refuses further registration - the contract
|
||||
// implementations answer storage.ErrQueueAlreadyStarted - and the legacy
|
||||
// adapter this path goes through drops that error, so the wrong order loses
|
||||
// consumers with nothing said about it. The memory backend does not care,
|
||||
// which is exactly why this cannot be left to be noticed in use.
|
||||
func TestConsumersAreRegisteredBeforeTheQueueIsStarted(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
q := newRecordingQueue()
|
||||
attachConsumersOnce(1, q)
|
||||
q.waitForRun(t)
|
||||
|
||||
seen := q.seen()
|
||||
runAt := -1
|
||||
registers := 0
|
||||
for i, e := range seen {
|
||||
switch {
|
||||
case e == "run":
|
||||
if runAt < 0 {
|
||||
runAt = i
|
||||
}
|
||||
case strings.HasPrefix(e, "register:"):
|
||||
registers++
|
||||
if runAt >= 0 {
|
||||
t.Errorf("%q came after Run; a running queue refuses registration", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if registers != 3 {
|
||||
t.Errorf("registered %d consumers, want 3; saw %v", registers, seen)
|
||||
}
|
||||
if runAt < 0 {
|
||||
t.Errorf("the queue was never started; saw %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// AfterResource runs again on every configuration reload, so the hook has to
|
||||
// be idempotent with respect to a given queue - not "does nothing the second
|
||||
// time". Registering twice on the same queue would give every message two
|
||||
// consumers and write every log row twice.
|
||||
func TestTheSameQueueIsNotGivenConsumersTwice(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
q := newRecordingQueue()
|
||||
attachConsumersOnce(1, q)
|
||||
q.waitForRun(t)
|
||||
attachConsumersOnce(1, q)
|
||||
|
||||
// Nothing to wait for on the second call, so give a wrong implementation
|
||||
// the time it would need to show up.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := len(q.seen()); n != 4 {
|
||||
t.Errorf("%d calls after attaching twice to the same queue, want 4 (3 registers + 1 run); saw %v", n, q.seen())
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: a reload builds a new adapter, and the
|
||||
// consumers on the old one are attached to a queue nobody publishes to any
|
||||
// more. A new generation must get its own set.
|
||||
func TestANewQueueGetsItsOwnConsumers(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
first := newRecordingQueue()
|
||||
attachConsumersOnce(1, first)
|
||||
first.waitForRun(t)
|
||||
|
||||
second := newRecordingQueue()
|
||||
attachConsumersOnce(2, second)
|
||||
second.waitForRun(t)
|
||||
|
||||
if n := len(second.seen()); n != 4 {
|
||||
t.Errorf("the queue from the second generation saw %d calls, want 4; saw %v", n, second.seen())
|
||||
}
|
||||
if n := len(first.seen()); n != 4 {
|
||||
t.Errorf("the queue from the first generation saw %d calls, want 4 - it should not have been touched again; saw %v", n, first.seen())
|
||||
}
|
||||
}
|
||||
|
||||
// Generation 0 means the configuration has no queue section at all, so nothing
|
||||
// was installed and the runtime hands back its own memory queue. That case
|
||||
// still has to get consumers - the registration it replaces was unconditional,
|
||||
// and dropping it would stop the login and operation logs for anyone who
|
||||
// commented the section out.
|
||||
func TestAnUnconfiguredQueueStillGetsConsumers(t *testing.T) {
|
||||
attachedQueue.Store(0)
|
||||
t.Cleanup(func() { attachedQueue.Store(0) })
|
||||
|
||||
q := newRecordingQueue()
|
||||
attachConsumersOnce(0, q)
|
||||
q.waitForRun(t)
|
||||
|
||||
if n := len(q.seen()); n != 4 {
|
||||
t.Errorf("an unconfigured queue saw %d calls, want 4; saw %v", n, q.seen())
|
||||
}
|
||||
|
||||
// And still only once.
|
||||
attachConsumersOnce(0, q)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := len(q.seen()); n != 4 {
|
||||
t.Errorf("generation 0 was attached to twice: %d calls, want 4; saw %v", n, q.seen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
"go-admin/common/health"
|
||||
commonmodels "go-admin/common/models"
|
||||
)
|
||||
|
||||
// schemaCheckName is what a failing schema reports itself as in /ready's body.
|
||||
const schemaCheckName = "schema"
|
||||
|
||||
// registerSchemaCheck adds the pending-migration check to readiness.
|
||||
//
|
||||
// Readiness rather than a refusal to start, and rather than a log line alone.
|
||||
// The two probes answer different questions: liveness is "restart me", and a
|
||||
// process whose database is on the wrong schema comes back to the same schema,
|
||||
// so restarting is not the answer. Readiness is "send me requests", and with a
|
||||
// schema the binary does not match the answer is no.
|
||||
//
|
||||
// Issue #919 is what the absence of this looked like: the process started,
|
||||
// both probes passed, and the first sign of trouble was a login failing with a
|
||||
// driver-level encoding error. Refusing to start would have been the wrong fix
|
||||
// - a process that exits tells an operator less than one that runs and says
|
||||
// why, and under an orchestrator it crash-loops - while a log line alone is
|
||||
// not something an orchestrator can act on.
|
||||
func registerSchemaCheck() {
|
||||
health.Register(schemaCheckName, schemaCheck)
|
||||
}
|
||||
|
||||
// schemaCheck fails while any tenant database is behind the migrations this
|
||||
// binary registers.
|
||||
//
|
||||
// Any one of them, rather than only the tenant being served: migrations are
|
||||
// applied to every database in one run, so one database behind means that run
|
||||
// did not finish. Serving the rest would let a half-applied deploy look like a
|
||||
// partial success.
|
||||
//
|
||||
// Evaluated per request rather than decided at start-up, so that running
|
||||
// migrate clears it without a restart.
|
||||
func schemaCheck(ctx context.Context) error {
|
||||
registered := migration.RegisteredVersions()
|
||||
if len(registered) == 0 {
|
||||
// Nothing registered means nothing can be pending, which is the honest
|
||||
// answer for a tree with no migrations. It is also what a broken build
|
||||
// would produce - the registry is filled by init() in packages the
|
||||
// binary has to link - so cmd/api's dependency test asserts the real
|
||||
// binary links them.
|
||||
return nil
|
||||
}
|
||||
|
||||
behind := make([]string, 0, 2)
|
||||
for name, db := range sdk.Runtime.GetAllDb() {
|
||||
applied, err := appliedVersions(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading applied migrations for %q: %w", name, err)
|
||||
}
|
||||
if pending := pendingVersions(registered, applied); len(pending) > 0 {
|
||||
behind = append(behind, fmt.Sprintf("%s is %d behind, first pending %s",
|
||||
name, len(pending), pending[0]))
|
||||
}
|
||||
}
|
||||
if len(behind) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(behind)
|
||||
return fmt.Errorf("%s; run `go-admin migrate -c <config>` and see `go-admin migrate status`",
|
||||
strings.Join(behind, "; "))
|
||||
}
|
||||
|
||||
// appliedVersions reads what sys_migration records for one database.
|
||||
//
|
||||
// A missing table is not an error: a database that has never been migrated has
|
||||
// applied nothing, which is exactly what the caller needs to hear, and is the
|
||||
// state a first deploy is in.
|
||||
func appliedVersions(ctx context.Context, db *gorm.DB) (map[string]bool, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("no database")
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&commonmodels.Migration{}) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
var rows []commonmodels.Migration
|
||||
if err := db.Select("version").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.Version] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pendingVersions returns the registered versions applied does not contain.
|
||||
//
|
||||
// Split out and taking both sides as arguments because the registry is
|
||||
// process-wide and filled by init() in packages cmd/api does not import: a
|
||||
// test in this package cannot arrange it, so the arranging part is the part
|
||||
// that is not tested here.
|
||||
func pendingVersions(registered []string, applied map[string]bool) []string {
|
||||
out := make([]string, 0)
|
||||
for _, v := range registered {
|
||||
if !applied[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
commonmodels "go-admin/common/models"
|
||||
)
|
||||
|
||||
func TestPendingVersionsReportsOnlyWhatIsNotApplied(t *testing.T) {
|
||||
registered := []string{"1000_a", "2000_b", "3000_c"}
|
||||
applied := map[string]bool{"1000_a": true, "3000_c": true}
|
||||
|
||||
got := pendingVersions(registered, applied)
|
||||
if len(got) != 1 || got[0] != "2000_b" {
|
||||
t.Errorf("pending = %v, want [2000_b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingVersionsIsEmptyWhenTheDatabaseIsCurrent(t *testing.T) {
|
||||
registered := []string{"1000_a", "2000_b"}
|
||||
applied := map[string]bool{"1000_a": true, "2000_b": true}
|
||||
|
||||
if got := pendingVersions(registered, applied); len(got) != 0 {
|
||||
t.Errorf("pending = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A row recorded that this binary no longer registers is not pending. It is
|
||||
// the orphan `migrate status` already reports, and readiness has nothing to
|
||||
// say about it: the schema is ahead, not behind, and requests will be served
|
||||
// correctly.
|
||||
func TestPendingVersionsIgnoresAppliedRowsNothingRegisters(t *testing.T) {
|
||||
registered := []string{"1000_a"}
|
||||
applied := map[string]bool{"1000_a": true, "9999_gone": true}
|
||||
|
||||
if got := pendingVersions(registered, applied); len(got) != 0 {
|
||||
t.Errorf("pending = %v, want none - an orphaned row is not a pending migration", got)
|
||||
}
|
||||
}
|
||||
|
||||
func memoryDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&commonmodels.Migration{}) })
|
||||
return db
|
||||
}
|
||||
|
||||
// A first deploy has no sys_migration table. That is "nothing applied", not an
|
||||
// error: reporting it as one would make the check fail for a reason the
|
||||
// operator cannot act on, on the one deployment where every migration really
|
||||
// is pending.
|
||||
func TestAppliedVersionsTreatsAMissingTableAsNothingApplied(t *testing.T) {
|
||||
db := memoryDB(t)
|
||||
db.Migrator().DropTable(&commonmodels.Migration{})
|
||||
|
||||
got, err := appliedVersions(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("appliedVersions: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("applied = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliedVersionsReadsWhatTheTableHolds(t *testing.T) {
|
||||
db := memoryDB(t)
|
||||
if err := db.AutoMigrate(&commonmodels.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
db.Create(&commonmodels.Migration{Version: "1000_a"})
|
||||
db.Create(&commonmodels.Migration{Version: "2000_b"})
|
||||
|
||||
got, err := appliedVersions(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("appliedVersions: %v", err)
|
||||
}
|
||||
if !got["1000_a"] || !got["2000_b"] || len(got) != 2 {
|
||||
t.Errorf("applied = %v, want the two rows written", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The check is only worth anything if the registry it reads is populated in
|
||||
// the binary that serves requests, and it is filled by init() in packages
|
||||
// cmd/api does not import - cmd/migrate blank-imports them, and cmd wires both
|
||||
// subcommands into one binary.
|
||||
//
|
||||
// This cannot be asserted from an ordinary test: importing the version package
|
||||
// to look at the registry would put it in the test binary's dependency graph
|
||||
// and pass whatever the real binary links. So ask the build instead.
|
||||
//
|
||||
// Without this, dropping those blank imports leaves a check that reports
|
||||
// "nothing pending" for every database forever, and every test above still
|
||||
// passes.
|
||||
func TestTheServingBinaryLinksTheMigrationRegistry(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", "go-admin").Output()
|
||||
if err != nil {
|
||||
t.Skipf("go list unavailable: %v", err)
|
||||
}
|
||||
deps := string(out)
|
||||
|
||||
const versions = "go-admin/cmd/migrate/migration/version"
|
||||
if !strings.Contains(deps, versions+"\n") {
|
||||
t.Errorf("the main package does not link %s, so the schema check would "+
|
||||
"read an empty registry and report every database as current", versions)
|
||||
}
|
||||
|
||||
// Negative control: a package the binary genuinely must not link, so that a
|
||||
// `deps` that somehow contained everything would fail here rather than pass
|
||||
// the assertion above for the wrong reason.
|
||||
const notLinked = "go-admin/tools/checksilent"
|
||||
if strings.Contains(deps, notLinked+"\n") {
|
||||
t.Errorf("%s is in the binary's dependency closure, so this test cannot "+
|
||||
"tell a real link from a query that matches anything", notLinked)
|
||||
}
|
||||
}
|
||||
+503
-44
@@ -2,10 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -13,16 +17,21 @@ import (
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/bootstrap"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/router"
|
||||
"go-admin/app/jobs"
|
||||
otherrouter "go-admin/app/other/router"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/global"
|
||||
"go-admin/common/health"
|
||||
common "go-admin/common/middleware"
|
||||
"go-admin/common/middleware/handler"
|
||||
"go-admin/common/storage"
|
||||
@@ -59,46 +68,129 @@ func init() {
|
||||
func setup() {
|
||||
// 注入配置扩展项
|
||||
config.ExtendConfig = &ext.ExtConfig
|
||||
|
||||
// Registered before the configuration is read. SetupConfig announces
|
||||
// AfterResource as soon as the callbacks that build the resources have
|
||||
// run, so a hook added after that call would miss the first round and the
|
||||
// queue would have no consumers until somebody edited the config file.
|
||||
sdk.Runtime.SetPhase(runtime.AfterResource, attachQueueConsumers)
|
||||
|
||||
// On AfterListen rather than on a bare goroutine from run(). Two reasons:
|
||||
// the phase runs behind core's panic guard, which does not reach across a
|
||||
// goroutine boundary - a panic while loading jobs used to take the whole
|
||||
// process down with a stack that named this file - and the jobs it starts
|
||||
// can call the API, which is only true once the socket is accepting.
|
||||
sdk.Runtime.SetPhase(runtime.AfterListen, startCronJobs)
|
||||
|
||||
// Registered before the configuration is read, because it registers a
|
||||
// callback rather than reading anything: the check runs per request and
|
||||
// asks the databases that exist then.
|
||||
registerSchemaCheck()
|
||||
|
||||
//1. 读取配置
|
||||
config.Setup(
|
||||
bootstrap.SetupConfig(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
database.Setup,
|
||||
storage.Setup,
|
||||
)
|
||||
//注册监听函数
|
||||
queue := sdk.Runtime.GetQueuePrefix("")
|
||||
queue.Register(global.LoginLog, models.SaveLoginLog)
|
||||
queue.Register(global.OperateLog, models.SaveOperaLog)
|
||||
queue.Register(global.ApiCheck, models.SaveSysApi)
|
||||
go queue.Run()
|
||||
|
||||
usageStr := `starting api server...`
|
||||
log.Info(usageStr)
|
||||
}
|
||||
|
||||
// startCronJobs registers the job implementations and starts a scheduler for
|
||||
// every tenant database.
|
||||
//
|
||||
// It is synchronous, like the phase that runs it. jobs.Setup returns now that
|
||||
// the `select {}` at the end of its per-tenant setup is gone, which is what
|
||||
// makes that possible; while it was there this could only be a goroutine, and
|
||||
// a goroutine is outside the panic guard.
|
||||
func startCronJobs() {
|
||||
jobs.InitJob()
|
||||
jobs.Setup(sdk.Runtime.GetAllDb())
|
||||
}
|
||||
|
||||
// attachedQueue is the queue generation the consumers are attached to, plus
|
||||
// one, so that the zero value means "attached to nothing yet". Written from
|
||||
// the goroutine running the phase, read from the next one - rounds never
|
||||
// overlap, but they are not the same goroutine.
|
||||
var attachedQueue atomic.Uint64
|
||||
|
||||
// attachQueueConsumers registers the log consumers against the queue that is
|
||||
// current, and starts it.
|
||||
//
|
||||
// It runs on AfterResource, so it runs again after every configuration reload
|
||||
// - and it has to. A reload rebuilds the queue adapter, and consumers
|
||||
// registered against the one that existed at start-up are attached to an
|
||||
// adapter nobody publishes to any more, so the login and operation logs stop
|
||||
// being written with nothing said about it.
|
||||
//
|
||||
// It is therefore idempotent with respect to a given queue rather than "does
|
||||
// nothing the second time": a new adapter gets a fresh set of consumers, the
|
||||
// same one gets none. Registering twice on the same queue would give every
|
||||
// message two consumers and write every log row twice.
|
||||
//
|
||||
// Generation 0 means the configuration has no queue section, so nothing was
|
||||
// installed and GetQueuePrefix hands back the runtime's own memory queue.
|
||||
// That case still gets consumers - it is what the previous unconditional
|
||||
// registration did, and dropping it would silently stop logging for anyone who
|
||||
// commented the section out - it just never gets them twice.
|
||||
func attachQueueConsumers() {
|
||||
attachConsumersOnce(storage.QueueGeneration(), sdk.Runtime.GetQueuePrefix(""))
|
||||
}
|
||||
|
||||
// attachConsumersOnce puts the log consumers on q and starts it, unless gen
|
||||
// says this queue already has them.
|
||||
//
|
||||
// Split out from attachQueueConsumers so that the order and the once-ness can
|
||||
// be checked against a queue the test controls: the sequence that matters here
|
||||
// cannot be read back out of a real adapter.
|
||||
func attachConsumersOnce(gen uint64, q corestorage.AdapterQueue) {
|
||||
if attachedQueue.Load() == gen+1 {
|
||||
return
|
||||
}
|
||||
attachedQueue.Store(gen + 1)
|
||||
|
||||
//注册监听函数
|
||||
q.Register(global.LoginLog, models.SaveLoginLog)
|
||||
q.Register(global.OperateLog, models.SaveOperaLog)
|
||||
q.Register(global.ApiCheck, models.SaveSysApi)
|
||||
|
||||
// Started only now, and by whoever registered. setupQueue deliberately
|
||||
// leaves it stopped: a queue that is already running refuses further
|
||||
// registration, and the adapter in this path drops that error on the
|
||||
// floor, so starting first loses consumers without a word.
|
||||
go q.Run()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Resolved first, and used both for the line it prints and for the
|
||||
// shutdown that spends it. Reading the configuration again at signal time
|
||||
// would let the two disagree, and the sum that gets printed is the whole
|
||||
// point of printing it.
|
||||
//
|
||||
// Refused rather than corrected, and refused before anything is built: a
|
||||
// budget that cannot be spent as written is a configuration error, and the
|
||||
// moment to say so is while nothing depends on this process yet.
|
||||
seconds, err := ext.ExtConfig.Shutdown.Budget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reportShutdownBudget(seconds)
|
||||
|
||||
if config.ApplicationConfig.Mode == pkg.ModeProd.String() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
initRouter()
|
||||
|
||||
for _, f := range AppRouters {
|
||||
f()
|
||||
}
|
||||
buildRouter()
|
||||
reportGeneratorWriteRoutes()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
jobs.InitJob()
|
||||
jobs.Setup(sdk.Runtime.GetAllDb())
|
||||
|
||||
}()
|
||||
|
||||
if apiCheck {
|
||||
var routers = sdk.Runtime.GetRouter()
|
||||
q := sdk.Runtime.GetQueuePrefix("")
|
||||
@@ -116,18 +208,17 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 服务连接
|
||||
if config.SslConfig.Enable {
|
||||
if err := srv.ListenAndServeTLS(config.SslConfig.Pem, config.SslConfig.KeyStr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
} else {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Armed before the server starts serving, and well before the readiness
|
||||
// banner: a signal arriving between "the process is up" and "the process
|
||||
// is listening for signals" reaches the default handler and kills it
|
||||
// without any of the shutdown below. That window is the whole reason
|
||||
// arming is separate from waiting.
|
||||
quit, disarmStopSignals := armStopSignals()
|
||||
|
||||
if err := startServing(srv, config.SslConfig.Enable, config.SslConfig.Pem, config.SslConfig.KeyStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(pkg.Red(string(global.LogoContent)))
|
||||
tip()
|
||||
fmt.Println(pkg.Green("Server run at:"))
|
||||
@@ -137,23 +228,363 @@ func run() error {
|
||||
fmt.Printf("- Local: http://localhost:%d/swagger/admin/index.html \r\n", config.ApplicationConfig.Port)
|
||||
fmt.Printf("- Network: %s://%s:%d/swagger/admin/index.html \r\n", "http", pkg.GetLocalHost(), config.ApplicationConfig.Port)
|
||||
fmt.Printf("%s Enter Control + C Shutdown Server \r\n", pkg.GetCurrentTimeStr())
|
||||
// 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
<-quit
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
log.Info("Shutdown Server ... ")
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server Shutdown:", err)
|
||||
serverErr, cleanupErr := gracefulShutdown(srv, quit, disarmStopSignals, budgetFrom(seconds))
|
||||
if serverErr != nil {
|
||||
// Not log.Fatal: that is an unconditional os.Exit(1), and Shutdown
|
||||
// reports an error exactly when connections were still in flight -
|
||||
// which is when the cleanup that ran after it mattered most.
|
||||
log.Error("Server Shutdown: ", serverErr)
|
||||
}
|
||||
if cleanupErr != nil {
|
||||
log.Error("Cleanup: ", cleanupErr)
|
||||
}
|
||||
log.Info("Server exiting")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// budget is the three waits a shutdown spends, in the order it spends them.
|
||||
type budget struct {
|
||||
drain time.Duration
|
||||
server time.Duration
|
||||
cleanup time.Duration
|
||||
}
|
||||
|
||||
// budgetFrom turns the resolved seconds into the durations the sequence waits
|
||||
// on.
|
||||
func budgetFrom(s ext.ShutdownBudget) budget {
|
||||
return budget{
|
||||
drain: time.Duration(s.Drain) * time.Second,
|
||||
server: time.Duration(s.Server) * time.Second,
|
||||
cleanup: time.Duration(s.Cleanup) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultBudget is what a process with no extend.shutdown section spends.
|
||||
func defaultBudget() budget {
|
||||
return budget{drain: drainTimeout, server: shutdownTimeout, cleanup: cleanupTimeout}
|
||||
}
|
||||
|
||||
// gracefulShutdown takes the process down in the order that gives something
|
||||
// else a chance to notice first.
|
||||
//
|
||||
// The whole order lives here, and run() is not the only caller: the signal
|
||||
// tests run this function rather than reproducing it. A test that reproduces a
|
||||
// sequence asserts against its own copy and stays green while the sequence it
|
||||
// was written for regresses.
|
||||
//
|
||||
// The caller has already taken the first signal off quit. quit is handed on
|
||||
// because a second signal during the drain window ends the window early -
|
||||
// somebody sending another kill wants this over with sooner - and because
|
||||
// until the window is over that signal must not reach the default handler and
|
||||
// kill the process outright.
|
||||
//
|
||||
// disarm is therefore called at the end of the window rather than on the first
|
||||
// signal. After it, a second signal is handled by the default disposition
|
||||
// again, which is the only way out of a Shutdown or a cleanup callback that
|
||||
// never returns. Restoring it any earlier would put every ordinary shutdown
|
||||
// inside that escape hatch for the whole length of the drain, where before
|
||||
// this window existed only a hung callback could reach it.
|
||||
//
|
||||
// The two waits' errors are returned separately rather than logged: they fail
|
||||
// for different reasons, and the caller decides what each is worth.
|
||||
func gracefulShutdown(srv *http.Server, quit <-chan os.Signal, disarm func(), b budget) (serverErr, cleanupErr error) {
|
||||
// Said before anything is taken apart. A configuration reload arriving in
|
||||
// this window would otherwise re-run AfterResource - rebuilding the pool
|
||||
// and the queue adapter, and re-registering consumers - on top of cleanup
|
||||
// that has already run.
|
||||
sdk.Runtime.BeginShutdown()
|
||||
|
||||
// Readiness fails from here, which is before the server stops accepting.
|
||||
// That order is necessary and not sufficient: with nothing between this
|
||||
// line and the listener closing, the two are microseconds apart and a
|
||||
// poller on a multi-second interval sees the refused connection instead of
|
||||
// the 503. The window below is what turns the order into something
|
||||
// observable - extend.shutdown.drain, which is zero unless it is
|
||||
// configured.
|
||||
health.BeginDraining()
|
||||
|
||||
// Keep-alive off for the same window, and for the same reason. The server
|
||||
// keeps connections alive while !disableKeepAlives && !shuttingDown(), and
|
||||
// shuttingDown() is only set by Shutdown itself - so without this line
|
||||
// every pooled connection stays open for the whole drain and is cut at the
|
||||
// end of it anyway, which is the cost of the window without its benefit.
|
||||
// This is the switch Shutdown flips, moved earlier by the window's length:
|
||||
// answers now carry Connection: close, and the idle connections a balancer
|
||||
// is holding are closed at once rather than when it next tries to use one.
|
||||
srv.SetKeepAlivesEnabled(false)
|
||||
|
||||
drain(quit, b.drain)
|
||||
|
||||
// Restored here, not on the first signal: from this point a second signal
|
||||
// must reach the default handler, so a shutdown that hangs can still be
|
||||
// interrupted.
|
||||
disarm()
|
||||
|
||||
log.Info("Shutdown Server ... ")
|
||||
serverErr = shutdownServer(srv, b.server)
|
||||
// Runs whether or not the wait above failed, and deliberately so: Shutdown
|
||||
// reports an error exactly when connections were still in flight, which is
|
||||
// when there is most left to clean up after.
|
||||
cleanupErr = runShutdownHooks(b.cleanup)
|
||||
log.Info("Server exiting")
|
||||
|
||||
return serverErr, cleanupErr
|
||||
}
|
||||
|
||||
// drain keeps serving for d, or until another stop signal arrives.
|
||||
//
|
||||
// Requests are answered normally throughout. Refusing them would move the
|
||||
// outage earlier rather than avoid it - the point of the window is that this
|
||||
// instance is still able to work while whoever routes to it stops routing.
|
||||
func drain(quit <-chan os.Signal, d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
log.Infof("Draining for %s: still serving, /ready answers 503 from here", d)
|
||||
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-quit:
|
||||
log.Info("Second signal during the drain window, closing the listener now")
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
// Reference stop grace periods, printed when nothing was configured to compare
|
||||
// against. They are three times apart, which is why the check below needs a
|
||||
// configured value rather than a constant of its own: a budget that overruns
|
||||
// under one of them fits comfortably under the other.
|
||||
const (
|
||||
dockerStopGraceSeconds = 10
|
||||
kubernetesGraceSeconds = 30
|
||||
)
|
||||
|
||||
// reportGeneratorWriteRoutes says whether this process serves the code
|
||||
// generator's writing endpoints, and to whom.
|
||||
//
|
||||
// The endpoints are gated on the mode, and the shipped configuration says dev -
|
||||
// so the deployment most likely to be exposed is the one that changed nothing,
|
||||
// and the one least likely to go looking. Silence there would leave the gate
|
||||
// technically correct and practically useless.
|
||||
//
|
||||
// Nothing is said in demo mode. The routes are registered, but DemoEvn refuses
|
||||
// all three by name, so a warning would describe an exposure that is not there.
|
||||
func reportGeneratorWriteRoutes() {
|
||||
if !generatorWriteRoutesNeedWarning() {
|
||||
return
|
||||
}
|
||||
log.Warnf("the code generator's writing endpoints are served in mode %q: "+
|
||||
"/api/v1/gen/{toproject,apitofile,todb} write Go and Vue source onto this host and rows "+
|
||||
"into this database, and they are in CasbinExclude, so any account that can log in may "+
|
||||
"call them. Set application.mode to prod or test on anything that is not a workstation, "+
|
||||
"then restart: these routes were registered at start-up and a configuration reload does "+
|
||||
"not rebuild them.",
|
||||
config.ApplicationConfig.Mode)
|
||||
}
|
||||
|
||||
// generatorWriteRoutesNeedWarning reports whether there is an exposure to warn
|
||||
// about: the endpoints are served, and nothing else is refusing them.
|
||||
//
|
||||
// Split from the logging so the decision can be tested. A warning nobody can
|
||||
// make fire is indistinguishable from no warning at all, and this one exists
|
||||
// precisely for the case nobody is looking at.
|
||||
func generatorWriteRoutesNeedWarning() bool {
|
||||
return otherrouter.GenWriteRoutesEnabled() && config.ApplicationConfig.Mode != "demo"
|
||||
}
|
||||
|
||||
// reportShutdownBudget states what a shutdown will spend and whether it fits.
|
||||
//
|
||||
// The sum is taken from the resolved values, not from the configuration file:
|
||||
// a field left out of extend.shutdown still costs its default, so adding up
|
||||
// what was written down understates the budget by exactly the fields nobody
|
||||
// wrote.
|
||||
func reportShutdownBudget(s ext.ShutdownBudget) {
|
||||
log.Infof("shutdown budget: drain %ds + server %ds + cleanup %ds = %ds",
|
||||
s.Drain, s.Server, s.Cleanup, s.Total())
|
||||
|
||||
if s.Grace <= 0 {
|
||||
log.Infof("shutdown budget: extend.shutdown.grace is not set, so nothing is compared against it - "+
|
||||
"for reference `docker stop` allows %ds and Kubernetes terminationGracePeriodSeconds defaults to %ds",
|
||||
dockerStopGraceSeconds, kubernetesGraceSeconds)
|
||||
return
|
||||
}
|
||||
if over := s.Overrun(); over > 0 {
|
||||
// A minimum, not a target. This is somebody else's deployment under
|
||||
// constraints this process cannot see, so the honest thing to state is
|
||||
// how much is missing - the repository's own files are where there is
|
||||
// standing to ask for headroom on top, and checksilent does that.
|
||||
log.Warnf("shutdown budget of %ds does not fit inside the %ds of extend.shutdown.grace: "+
|
||||
"SIGKILL arrives while the cleanup callbacks are still running, and the work they "+
|
||||
"were about to finish is lost. It needs at least %ds more, or %ds less budget.",
|
||||
s.Total(), s.Grace, over, over)
|
||||
return
|
||||
}
|
||||
log.Infof("shutdown budget of %ds fits inside the %ds of extend.shutdown.grace", s.Total(), s.Grace)
|
||||
}
|
||||
|
||||
// The budgets a shutdown spends when extend.shutdown configures nothing:
|
||||
// drainTimeout keeps the process serving after the stop signal, then
|
||||
// shutdownTimeout waits for in-flight requests, then cleanupTimeout is what
|
||||
// the BeforeExit callbacks get.
|
||||
//
|
||||
// The seconds come from config, which is where an absent field falls back, so
|
||||
// the default is one number rather than two that can drift apart.
|
||||
//
|
||||
// They are consumed one after the other, so their sum is what has to stay
|
||||
// inside the orchestrator's grace period: `docker stop` allows 10s by default
|
||||
// before it sends SIGKILL, and 0+5+3 leaves room for the process to finish
|
||||
// returning. Raising one without lowering another buys nothing - the budget
|
||||
// that runs out is the orchestrator's, and reportShutdownBudget is what says
|
||||
// so at start-up.
|
||||
var (
|
||||
drainTimeout = time.Duration(ext.DefaultDrainSeconds) * time.Second
|
||||
shutdownTimeout = time.Duration(ext.DefaultServerSeconds) * time.Second
|
||||
cleanupTimeout = time.Duration(ext.DefaultCleanupSeconds) * time.Second
|
||||
)
|
||||
|
||||
// armStopSignals registers for the stop signals and returns the channel they
|
||||
// arrive on together with the function that restores the default disposition.
|
||||
//
|
||||
// SIGTERM is what actually arrives in production: `docker stop`, a Kubernetes
|
||||
// pod deletion and `systemctl stop` all send it, and Go terminates the process
|
||||
// immediately for a signal nobody listens for. Registering only os.Interrupt
|
||||
// meant every graceful shutdown below the wait was dead code outside a
|
||||
// terminal.
|
||||
//
|
||||
// Registering is separate from waiting so a caller can arm before it announces
|
||||
// that it is ready: a signal that arrives between the two is delivered to the
|
||||
// default handler, which for both of these means the process dies without
|
||||
// running any of this.
|
||||
func armStopSignals() (<-chan os.Signal, func()) {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
return quit, func() { signal.Stop(quit) }
|
||||
}
|
||||
|
||||
// startServing binds srv.Addr, hands the listener to srv on its own goroutine,
|
||||
// and announces AfterListen.
|
||||
//
|
||||
// The bind is done here rather than left to ListenAndServe, which binds on the
|
||||
// goroutine that serves. That put the failure every deployment actually hits -
|
||||
// "address already in use" - on a goroutine nobody was reading, so the banner
|
||||
// went on to claim the server was up, and there would be no way to keep
|
||||
// AfterListen from announcing a socket that does not exist. A hook there is
|
||||
// promised a reachable port; the only way to keep that promise is for the bind
|
||||
// to have already happened on this goroutine.
|
||||
//
|
||||
// AfterListen is announced synchronously. Running it in a goroutine to save the
|
||||
// few milliseconds would let it overlap the shutdown: on a fast SIGTERM the
|
||||
// cleanup callbacks could finish before the startup ones had.
|
||||
//
|
||||
// Both ways of failing to start are therefore checked before the announcement:
|
||||
// the bind, and - with ssl enabled - the certificate.
|
||||
func startServing(srv *http.Server, useTLS bool, pem, key string) error {
|
||||
if useTLS {
|
||||
// Read the certificate before anything is announced. ServeTLS reads
|
||||
// these files itself, but on the serving goroutine - so a bad
|
||||
// certificate used to surface after AfterListen had already promised a
|
||||
// reachable port. Loading it here costs one extra read and moves the
|
||||
// failure onto this goroutine, where run() can return it.
|
||||
//
|
||||
// ServeTLS still does the real work below rather than this handing it a
|
||||
// tls.Listener: that is what sets up HTTP/2 negotiation, and taking it
|
||||
// over here would quietly drop h2 for every TLS deployment.
|
||||
if _, err := tls.LoadX509KeyPair(pem, key); err != nil {
|
||||
return errors.Wrap(err, "tls certificate")
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "listen")
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 服务连接
|
||||
var err error
|
||||
if useTLS {
|
||||
err = srv.ServeTLS(ln, pem, key)
|
||||
} else {
|
||||
err = srv.Serve(ln)
|
||||
}
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
// Still fatal, as it was. Neither the bind nor the certificate is
|
||||
// among the errors that reach here any more - both are checked
|
||||
// above, on the caller's goroutine. What is left is a serve that
|
||||
// failed after the port was taken, and carrying on would park the
|
||||
// process on <-quit with nothing serving.
|
||||
log.Fatal("serve: ", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sdk.Runtime.RunPhase(runtime.AfterListen)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdownServer stops srv, giving in-flight requests up to timeout to finish.
|
||||
//
|
||||
// It returns the error instead of exiting on it. A caller that exits here skips
|
||||
// its own cleanup, and Shutdown fails precisely when there was something left
|
||||
// to clean up after.
|
||||
func shutdownServer(srv *http.Server, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// runShutdownHooks runs the BeforeExit callbacks with timeout to share.
|
||||
//
|
||||
// What the budget bounds is the wait, not the work. When it is gone RunShutdown
|
||||
// stops waiting and returns; a callback that never looks at its context carries
|
||||
// on until the process exits, and may leave a partial write behind. Go cannot
|
||||
// cancel a function that does not check for cancellation, which is why the
|
||||
// callbacks are handed a context at all.
|
||||
func runShutdownHooks(timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return sdk.Runtime.RunShutdown(ctx)
|
||||
}
|
||||
|
||||
// buildRouter announces BeforeRouter, builds the engine, and then drains the
|
||||
// startup registries.
|
||||
//
|
||||
// The order is the contract. BeforeRouter is the last point at which a module
|
||||
// can still affect how routes are built, so it has to run while there is no
|
||||
// engine yet. The before registry runStartupHooks drains is a different moment
|
||||
// despite the name: those callbacks run after initRouter has built the engine.
|
||||
// Two lines apart, and describing them as equivalent is a mistake this
|
||||
// repository has already made once in writing.
|
||||
func buildRouter() {
|
||||
sdk.Runtime.RunPhase(runtime.BeforeRouter)
|
||||
initRouter()
|
||||
runStartupHooks()
|
||||
}
|
||||
|
||||
// runStartupHooks runs the router registries and then the before callbacks.
|
||||
//
|
||||
// The package-level slice runs first and in its existing order, so a fork that
|
||||
// only ever appended to AppRouters sees no change at all. The core registry
|
||||
// runs second, through RunAppRouters: a module can register through
|
||||
// sdk.Runtime.SetAppRouters and no longer has to import this command package -
|
||||
// which is a main package's plumbing - just to be routed.
|
||||
//
|
||||
// The loop over the core registry now lives in core, which is what brings the
|
||||
// panic guard and the registration seal with it. RunBefore closes a gap rather
|
||||
// than moving one: the open-source edition never executed the before callbacks
|
||||
// at all, so SetBefore was accepted and silently ignored. It has to stay ahead
|
||||
// of ListenAndServe, because a callback registered WithFatal exits the process
|
||||
// and that must not happen to one that is already serving.
|
||||
func runStartupHooks() {
|
||||
for _, f := range AppRouters {
|
||||
f()
|
||||
}
|
||||
sdk.Runtime.RunAppRouters()
|
||||
sdk.Runtime.RunBefore()
|
||||
}
|
||||
|
||||
//var Router runtime.Router
|
||||
|
||||
func tip() {
|
||||
@@ -179,10 +610,38 @@ func initRouter() {
|
||||
r.Use(handler.TlsHandler())
|
||||
}
|
||||
//r.Use(middleware.Metrics())
|
||||
r.Use(common.Sentinel()).
|
||||
r.Use(exemptProbes(common.Sentinel())).
|
||||
Use(common.RequestId(pkg.TrafficKey)).
|
||||
Use(api.SetRequestLogger)
|
||||
|
||||
common.InitMiddleware(r)
|
||||
|
||||
}
|
||||
|
||||
// probePaths are the two routes the rate limiter must not answer for.
|
||||
var probePaths = map[string]bool{
|
||||
otherrouter.APIPrefix + otherrouter.HealthPath: true,
|
||||
otherrouter.APIPrefix + otherrouter.ReadyPath: true,
|
||||
}
|
||||
|
||||
// exemptProbes wraps a middleware so the health and readiness routes skip it.
|
||||
//
|
||||
// The limiter is installed on the engine and the probes are routes like any
|
||||
// other, so above the threshold they are answered with 429 as well. A liveness
|
||||
// probe that collects 429s fails its threshold and the container is restarted,
|
||||
// which takes capacity out of a deployment that is already short of it and
|
||||
// pushes the rest closer to the threshold - the limiter working exactly as
|
||||
// intended is what causes it. It is the argument common/health makes about
|
||||
// restarting a process whose database is unreachable, applied to load.
|
||||
//
|
||||
// Wrapping rather than teaching the limiter about these paths: the limiter
|
||||
// lives under common/, which may not import the package that registers them.
|
||||
func exemptProbes(h gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if probePaths[c.FullPath()] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
h(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// freshRuntime hands the test its own Runtime and puts the old one back.
|
||||
//
|
||||
// Both registries close permanently the first time they are run, and
|
||||
// sdk.Runtime is a process-wide singleton, so a test that runs the startup
|
||||
// hooks would otherwise leave every later test in this binary registering into
|
||||
// a closed registry - which is only an ERROR log, not a failure. The symptom
|
||||
// is a test that passes alone and loses its routes when run with the others.
|
||||
func freshRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := sdk.Runtime
|
||||
t.Cleanup(func() { sdk.Runtime = previous })
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
}
|
||||
|
||||
// Acceptance 1 and 2 together: the package-level slice a fork appends to and
|
||||
// the core registry a module registers through both run, package-level first,
|
||||
// registration order preserved inside each.
|
||||
//
|
||||
// The order matters beyond neatness. A module that appends to AppRouters has to
|
||||
// import go-admin/cmd/api, which is why every module used to need a seven-line
|
||||
// file in the command package; SetAppRouters is the way out of that. Running
|
||||
// the old registry first is what makes the change invisible to anyone who never
|
||||
// takes it.
|
||||
func TestRunStartupHooksRunsBothRegistriesInOrder(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
savedPackage := AppRouters
|
||||
t.Cleanup(func() { AppRouters = savedPackage })
|
||||
|
||||
var order []string
|
||||
AppRouters = []func(){
|
||||
func() { order = append(order, "package-1") },
|
||||
func() { order = append(order, "package-2") },
|
||||
}
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-1") })
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-2") })
|
||||
|
||||
runStartupHooks()
|
||||
|
||||
const want = "package-1,package-2,runtime-1,runtime-2"
|
||||
if got := strings.Join(order, ","); got != want {
|
||||
t.Errorf("ran %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 17: a before callback registered through core actually runs.
|
||||
//
|
||||
// It did not, ever: core stored the callbacks and nothing executed them, so
|
||||
// SetBefore was accepted and silently did nothing. The gap survived because
|
||||
// core offered the registry without ever running it, leaving each consumer to
|
||||
// write - or forget - its own loop.
|
||||
func TestBeforeCallbacksRun(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
savedPackage := AppRouters
|
||||
t.Cleanup(func() { AppRouters = savedPackage })
|
||||
AppRouters = nil
|
||||
|
||||
var order []string
|
||||
sdk.Runtime.SetBefore(func() { order = append(order, "before-1") })
|
||||
sdk.Runtime.SetBefore(func() { order = append(order, "before-2") })
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "router") })
|
||||
|
||||
runStartupHooks()
|
||||
|
||||
// Routers first, then before: both happen ahead of ListenAndServe, and a
|
||||
// router callback is what puts the engine in place for anything that comes
|
||||
// after it.
|
||||
const want = "router,before-1,before-2"
|
||||
if got := strings.Join(order, ","); got != want {
|
||||
t.Errorf("ran %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A panicking module must not take the server down with it. The guard lives in
|
||||
// core; this asserts that go-admin actually goes through it rather than around
|
||||
// it with a loop of its own.
|
||||
func TestAPanickingRouterDoesNotStopStartup(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
savedPackage := AppRouters
|
||||
t.Cleanup(func() { AppRouters = savedPackage })
|
||||
AppRouters = nil
|
||||
|
||||
var order []string
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "first") })
|
||||
sdk.Runtime.SetAppRouters(func() { panic("a third-party module blew up") })
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "third") })
|
||||
sdk.Runtime.SetBefore(func() { order = append(order, "before") })
|
||||
|
||||
runStartupHooks()
|
||||
|
||||
const want = "first,third,before"
|
||||
if got := strings.Join(order, ","); got != want {
|
||||
t.Errorf("ran %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The default AppRouters must keep the admin routes on it. Emptying the slice
|
||||
// would not fail to compile anywhere - it would just serve a server with no
|
||||
// admin API and no error.
|
||||
func TestAdminRouterIsRegisteredOnThePackageSlice(t *testing.T) {
|
||||
if len(AppRouters) == 0 {
|
||||
t.Fatal("AppRouters is empty; the admin router is registered in init()")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
otherrouter "go-admin/app/other/router"
|
||||
"go-admin/common/health"
|
||||
ext "go-admin/config"
|
||||
)
|
||||
|
||||
// The seconds in the configuration and the durations the sequence waits on are
|
||||
// two spellings of one budget, and only one of them is printed at start-up.
|
||||
func TestBudgetFromSeconds(t *testing.T) {
|
||||
got := budgetFrom(ext.ShutdownBudget{Drain: 10, Server: 5, Cleanup: 3})
|
||||
want := budget{
|
||||
drain: 10 * time.Second,
|
||||
server: 5 * time.Second,
|
||||
cleanup: 3 * time.Second,
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("budgetFrom = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The package variables and config.Default*Seconds have to say the same thing.
|
||||
// They are the same default written twice - once as durations for the shutdown
|
||||
// and once as seconds for the fallback - and a deployment that configures
|
||||
// nothing is entitled to one answer, not two.
|
||||
func TestDefaultBudgetIsTheConfiguredFallback(t *testing.T) {
|
||||
unconfigured, err := ext.Shutdown{}.Budget()
|
||||
if err != nil {
|
||||
t.Fatalf("the empty section did not resolve: %v", err)
|
||||
}
|
||||
if got, want := defaultBudget(), budgetFrom(unconfigured); got != want {
|
||||
t.Errorf("defaultBudget = %+v, want the unconfigured budget %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The rate limiter must not answer for the probes.
|
||||
//
|
||||
// It is installed on the engine, so without this the probes are limited like
|
||||
// any other route and answer 429 above the threshold. A liveness probe that
|
||||
// collects 429s fails its threshold and the container is restarted - taking
|
||||
// capacity out of a deployment that is already short of it and pushing the
|
||||
// rest closer to the threshold. The limiter working exactly as designed is
|
||||
// what would cause it.
|
||||
//
|
||||
// The stand-in rejects everything rather than being a real limiter: what is
|
||||
// under test is which requests reach it, and a real one would need the traffic
|
||||
// to cross a threshold before it said anything.
|
||||
func TestTheProbesSkipTheRateLimiter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var reached []string
|
||||
r := gin.New()
|
||||
r.Use(exemptProbes(func(c *gin.Context) {
|
||||
reached = append(reached, c.FullPath())
|
||||
c.AbortWithStatus(http.StatusTooManyRequests)
|
||||
}))
|
||||
v1 := r.Group(otherrouter.APIPrefix)
|
||||
otherrouter.RegisterMonitorRouter(v1)
|
||||
v1.GET("/business", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
limited bool
|
||||
}{
|
||||
{otherrouter.APIPrefix + otherrouter.HealthPath, false},
|
||||
{otherrouter.APIPrefix + otherrouter.ReadyPath, false},
|
||||
// Not a probe, and deliberately not exempt: the exemption is for the
|
||||
// two routes an orchestrator acts on, not for everything under
|
||||
// /api/v1 that happens to be unauthenticated.
|
||||
{otherrouter.APIPrefix + "/metrics", true},
|
||||
{otherrouter.APIPrefix + "/business", true},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
|
||||
if tc.limited {
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("answered %d, want the middleware's 429 - it was skipped for a route that is not a probe", w.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
t.Errorf("answered 429; a probe that can be rate-limited gets the container restarted under load")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Said separately, because a probe could also answer 429 by itself: what
|
||||
// has to be true is that the middleware never saw the request.
|
||||
for _, p := range reached {
|
||||
if probePaths[p] {
|
||||
t.Errorf("the middleware ran for %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /health has to stay 200 while draining, and it is the assertion most easily
|
||||
// lost by accident: making the liveness probe follow the readiness flag reads
|
||||
// like tidying up, and it turns every rolling restart into a kubelet-issued
|
||||
// kill part-way through the drain.
|
||||
func TestHealthStaysUpWhileDraining(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
v1 := r.Group(otherrouter.APIPrefix)
|
||||
otherrouter.RegisterMonitorRouter(v1)
|
||||
|
||||
ask := func(path string) int {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return w.Code
|
||||
}
|
||||
|
||||
if got := ask(otherrouter.APIPrefix + otherrouter.HealthPath); got != http.StatusOK {
|
||||
t.Fatalf("/health answered %d before draining, want 200", got)
|
||||
}
|
||||
|
||||
// Process-wide and one-way - nothing clears it - so this is the last thing
|
||||
// in this package that may run in-process and care. Everything else that
|
||||
// exercises draining does so in a child process of its own.
|
||||
health.BeginDraining()
|
||||
|
||||
if got := ask(otherrouter.APIPrefix + otherrouter.HealthPath); got != http.StatusOK {
|
||||
t.Errorf("/health answered %d while draining, want 200 - liveness is "+
|
||||
"\"should I restart you\", and the answer during a drain is no", got)
|
||||
}
|
||||
if got := ask(otherrouter.APIPrefix + otherrouter.ReadyPath); got != http.StatusServiceUnavailable {
|
||||
t.Errorf("/ready answered %d while draining, want 503", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
|
||||
otherrouter "go-admin/app/other/router"
|
||||
)
|
||||
|
||||
// The signal path cannot be exercised in-process: delivering a signal to the
|
||||
// test binary would race with the test framework, and the disposition changes
|
||||
// are global. So the test re-executes itself as a child, and the child runs
|
||||
// gracefulShutdown - the same function run() runs, not a second copy of the
|
||||
// sequence. A test that reproduces the sequence asserts against its own copy:
|
||||
// move BeginDraining after the drain window and the process regresses while
|
||||
// the test stays green, which is the failure mode this file exists to avoid.
|
||||
//
|
||||
// The child serves the real probe routes on an http.Server of its own rather
|
||||
// than the configured one: this repository's CI has no database
|
||||
// (.github/workflows/go.yml runs neither MySQL nor a sqlite-tagged build), and
|
||||
// none of what is under test needs one. /ready answers 503 either way - with
|
||||
// no database its checks fail - so the assertions below are on the draining
|
||||
// answer specifically, not on the status code alone.
|
||||
const (
|
||||
childEnv = "GO_ADMIN_SIGNAL_CHILD"
|
||||
childStuckEnv = "GO_ADMIN_SIGNAL_CHILD_STUCK"
|
||||
childHangConn = "GO_ADMIN_SIGNAL_CHILD_HANGCONN"
|
||||
childSlowCleanup = "GO_ADMIN_SIGNAL_CHILD_SLOWCLEANUP"
|
||||
childDrainMS = "GO_ADMIN_SIGNAL_CHILD_DRAIN_MS"
|
||||
markerAddr = "CHILD-ADDR"
|
||||
markerReady = "CHILD-READY"
|
||||
markerSignal = "CHILD-SIGNAL"
|
||||
markerShutdown = "CHILD-SHUTDOWN-OK"
|
||||
markerCleanup = "CHILD-CLEANUP-RAN"
|
||||
markerTook = "CHILD-TOOK-NS"
|
||||
markerExiting = "CHILD-EXITING"
|
||||
)
|
||||
|
||||
// childPingRoute is an ordinary route, registered beside the probes so the
|
||||
// window can be checked for what it promises: requests arriving inside it are
|
||||
// served, not refused. Refusing them would move the outage earlier instead of
|
||||
// avoiding it.
|
||||
const childPingRoute = "/signal-test-ping"
|
||||
|
||||
var (
|
||||
readyPath = otherrouter.APIPrefix + otherrouter.ReadyPath
|
||||
healthPath = otherrouter.APIPrefix + otherrouter.HealthPath
|
||||
pingPath = otherrouter.APIPrefix + childPingRoute
|
||||
)
|
||||
|
||||
// TestSignalChild is the child process. It is skipped in a normal run.
|
||||
func TestSignalChild(t *testing.T) {
|
||||
if os.Getenv(childEnv) != "1" {
|
||||
t.Skip("child process entry point")
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
v1 := engine.Group(otherrouter.APIPrefix)
|
||||
otherrouter.RegisterMonitorRouter(v1)
|
||||
v1.GET(childPingRoute, func(c *gin.Context) { c.String(http.StatusOK, "pong") })
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Println("listen:", err)
|
||||
os.Exit(3)
|
||||
}
|
||||
// accepted fires once the server has taken a connection off the listener.
|
||||
// Dialling is not enough: Shutdown only waits for connections the server
|
||||
// has already accepted, so calling it between the dial and the accept
|
||||
// finds nothing to wait for and returns immediately.
|
||||
accepted := make(chan struct{}, 1)
|
||||
srv := &http.Server{
|
||||
Handler: engine,
|
||||
ConnState: func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
|
||||
// The budget the child spends. Nothing here calls bootstrap.SetupConfig, so
|
||||
// with no environment set this is the budget of a deployment that
|
||||
// configures no extend.shutdown section at all.
|
||||
b := defaultBudget()
|
||||
if ms := os.Getenv(childDrainMS); ms != "" {
|
||||
n, err := strconv.Atoi(ms)
|
||||
if err != nil {
|
||||
fmt.Println("drain:", err)
|
||||
os.Exit(4)
|
||||
}
|
||||
b.drain = time.Duration(n) * time.Millisecond
|
||||
}
|
||||
|
||||
// A BeforeExit callback, registered the way a module would. What the tests
|
||||
// below care about is whether it runs at all - after a Shutdown that
|
||||
// failed, and after its own budget has been spent.
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
switch {
|
||||
case os.Getenv(childStuckEnv) == "1":
|
||||
// Stands in for a cleanup hook that never finishes. The point of
|
||||
// restoring the signal disposition after the drain window is that
|
||||
// a second signal still reaches the default handler and kills this.
|
||||
time.Sleep(2 * time.Minute)
|
||||
case os.Getenv(childSlowCleanup) == "1":
|
||||
// Outlasts the budget on purpose, and does not consult ctx - which
|
||||
// is the case the contract is explicit about: what the context
|
||||
// bounds is the wait, not the work.
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
fmt.Println(markerCleanup)
|
||||
_ = os.Stdout.Sync()
|
||||
})
|
||||
switch {
|
||||
case os.Getenv(childStuckEnv) == "1":
|
||||
b.cleanup = 2 * time.Minute
|
||||
case os.Getenv(childSlowCleanup) == "1":
|
||||
b.cleanup = 300 * time.Millisecond
|
||||
}
|
||||
|
||||
// Arm before announcing readiness. Doing it the other way round leaves a
|
||||
// window in which the parent's signal reaches the default handler and
|
||||
// kills the child before any of this runs - which is exactly the failure
|
||||
// this whole change is about, so the test must not reproduce it by
|
||||
// accident.
|
||||
quit, disarm := armStopSignals()
|
||||
|
||||
fmt.Println(markerAddr, ln.Addr().String())
|
||||
fmt.Println(markerReady)
|
||||
_ = os.Stdout.Sync()
|
||||
|
||||
sig := <-quit
|
||||
fmt.Println(markerSignal, sig)
|
||||
_ = os.Stdout.Sync()
|
||||
|
||||
if os.Getenv(childHangConn) == "1" {
|
||||
// Dialled here, not at start-up. net/http stops counting a StateNew
|
||||
// connection against Shutdown once it is more than five seconds old,
|
||||
// so a connection opened before the wait would age out on a slow CI
|
||||
// run and Shutdown would succeed - leaving the test asserting nothing.
|
||||
c, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
fmt.Println("dial:", err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// And wait for the accept, for the opposite reason: an unaccepted
|
||||
// connection is not one Shutdown waits for either.
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(10 * time.Second):
|
||||
fmt.Println("the server never accepted the stalling connection")
|
||||
os.Exit(6)
|
||||
}
|
||||
|
||||
// A connection that has sent nothing keeps Shutdown busy: net/http
|
||||
// only treats a StateNew connection as idle once it is more than five
|
||||
// seconds old. A short budget makes the timeout deterministic without
|
||||
// waiting out the real one.
|
||||
b.server = 300 * time.Millisecond
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
serverErr, cleanupErr := gracefulShutdown(srv, quit, disarm, b)
|
||||
spent := time.Since(started)
|
||||
|
||||
if serverErr != nil {
|
||||
// Deliberately not fatal, and deliberately not a bare return: the
|
||||
// point is that whatever follows still runs.
|
||||
fmt.Println("shutdown error:", serverErr)
|
||||
} else {
|
||||
fmt.Println(markerShutdown)
|
||||
}
|
||||
if cleanupErr != nil {
|
||||
fmt.Println("cleanup error:", cleanupErr)
|
||||
}
|
||||
fmt.Println(markerTook, spent.Nanoseconds())
|
||||
fmt.Println(markerExiting)
|
||||
_ = os.Stdout.Sync()
|
||||
}
|
||||
|
||||
func startChild(t *testing.T, stuck bool, extraEnv ...string) (*exec.Cmd, chan string) {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestSignalChild", "-test.v")
|
||||
cmd.Env = append(os.Environ(), childEnv+"=1")
|
||||
if stuck {
|
||||
cmd.Env = append(cmd.Env, childStuckEnv+"=1")
|
||||
}
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
cmd.Stdout = w
|
||||
cmd.Stderr = w
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start child: %v", err)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
lines := make(chan string, 256)
|
||||
go func() {
|
||||
defer close(lines)
|
||||
buf := make([]byte, 4096)
|
||||
var acc strings.Builder
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
acc.Write(buf[:n])
|
||||
for {
|
||||
s := acc.String()
|
||||
i := strings.IndexByte(s, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
lines <- s[:i]
|
||||
acc.Reset()
|
||||
acc.WriteString(s[i+1:])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if acc.Len() > 0 {
|
||||
lines <- acc.String()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
_ = r.Close()
|
||||
})
|
||||
return cmd, lines
|
||||
}
|
||||
|
||||
// await drains lines until one contains want, or the deadline passes. It
|
||||
// returns everything it saw, so a failure says what the child actually did,
|
||||
// and the matching line, so a marker can carry a value.
|
||||
func await(t *testing.T, lines chan string, want string, d time.Duration) ([]string, string) {
|
||||
t.Helper()
|
||||
var seen []string
|
||||
deadline := time.After(d)
|
||||
for {
|
||||
select {
|
||||
case l, ok := <-lines:
|
||||
if !ok {
|
||||
t.Fatalf("child output ended before %q; saw:\n%s", want, strings.Join(seen, "\n"))
|
||||
}
|
||||
seen = append(seen, l)
|
||||
if strings.Contains(l, want) {
|
||||
return seen, l
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for %q; saw:\n%s", want, strings.Join(seen, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// childAddr waits for the address the child is listening on.
|
||||
func childAddr(t *testing.T, lines chan string) string {
|
||||
t.Helper()
|
||||
_, line := await(t, lines, markerAddr, 30*time.Second)
|
||||
fields := strings.Fields(line)
|
||||
return fields[len(fields)-1]
|
||||
}
|
||||
|
||||
// took reads the nanoseconds gracefulShutdown spent, as the child measured
|
||||
// them. Measured inside the child on purpose: the parent's own clock includes
|
||||
// process scheduling, which is the noise the tightest assertion here cannot
|
||||
// afford.
|
||||
func took(t *testing.T, lines chan string, d time.Duration) time.Duration {
|
||||
t.Helper()
|
||||
_, line := await(t, lines, markerTook, d)
|
||||
fields := strings.Fields(line)
|
||||
ns, err := strconv.ParseInt(fields[len(fields)-1], 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("unreadable %s line %q: %v", markerTook, line, err)
|
||||
}
|
||||
return time.Duration(ns)
|
||||
}
|
||||
|
||||
// sample is one answer, or the refusal that replaced it.
|
||||
type sample struct {
|
||||
at time.Time
|
||||
path string
|
||||
// status is zero when the connection could not be made at all, which is
|
||||
// what a closed listener looks like from outside.
|
||||
status int
|
||||
draining bool
|
||||
// willClose is what the server answered about the connection: the header
|
||||
// it sends is Connection: close, which the transport consumes and reports
|
||||
// here rather than leaving in Response.Header.
|
||||
willClose bool
|
||||
}
|
||||
|
||||
// probe asks once, on a connection of its own.
|
||||
//
|
||||
// A new transport per request, because a connection opened before the signal
|
||||
// can still be served after the listener is closed: reusing one would let this
|
||||
// test pass against a shutdown that had already broken the listener. Keep-alive
|
||||
// is left enabled so the server's own Connection: close is observable - a
|
||||
// client that asked for close would get that header back either way, and the
|
||||
// assertion would prove nothing.
|
||||
func probe(addr, path string) sample {
|
||||
tr := &http.Transport{}
|
||||
defer tr.CloseIdleConnections()
|
||||
c := &http.Client{Transport: tr, Timeout: 3 * time.Second}
|
||||
|
||||
s := sample{at: time.Now(), path: path}
|
||||
resp, err := c.Get("http://" + addr + path)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
s.status = resp.StatusCode
|
||||
s.willClose = resp.Close
|
||||
s.draining = strings.Contains(string(body), `"status":"draining"`)
|
||||
return s
|
||||
}
|
||||
|
||||
// watcher polls the child until it stops accepting connections, keeping every
|
||||
// answer.
|
||||
type watcher struct {
|
||||
mu sync.Mutex
|
||||
samples []sample
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func watch(addr string, paths ...string) *watcher {
|
||||
w := &watcher{done: make(chan struct{})}
|
||||
go func() {
|
||||
defer close(w.done)
|
||||
for {
|
||||
refused := false
|
||||
for _, p := range paths {
|
||||
s := probe(addr, p)
|
||||
w.mu.Lock()
|
||||
w.samples = append(w.samples, s)
|
||||
w.mu.Unlock()
|
||||
if s.status == 0 {
|
||||
refused = true
|
||||
}
|
||||
}
|
||||
if refused {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return w
|
||||
}
|
||||
|
||||
// sawDraining reports whether /ready has answered "draining" yet.
|
||||
func (w *watcher) sawDraining() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, s := range w.samples {
|
||||
if s.path == readyPath && s.draining {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (w *watcher) wait(t *testing.T, d time.Duration) []sample {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-w.done:
|
||||
case <-time.After(d):
|
||||
t.Fatal("the child never stopped accepting connections")
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.samples
|
||||
}
|
||||
|
||||
func describe(samples []sample) string {
|
||||
var b strings.Builder
|
||||
for _, s := range samples {
|
||||
fmt.Fprintf(&b, " %s %s -> %d draining=%v willClose=%v\n",
|
||||
s.at.Format("15:04:05.000"), s.path, s.status, s.draining, s.willClose)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Acceptance 19. Registering only os.Interrupt meant SIGTERM - the signal
|
||||
// `docker stop`, Kubernetes and systemd all send - terminated the process
|
||||
// before any of the shutdown path ran. Both must now reach it.
|
||||
func TestBothSignalsRunTheShutdownPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sig syscall.Signal
|
||||
}{
|
||||
{"SIGINT", syscall.SIGINT},
|
||||
{"SIGTERM", syscall.SIGTERM},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, lines := startChild(t, false)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(tc.sig); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
await(t, lines, markerShutdown, 10*time.Second)
|
||||
await(t, lines, markerExiting, 10*time.Second)
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 20. quit is a buffered channel and signal.Notify stays armed, so
|
||||
// without restoring the disposition a second signal only refills the buffer:
|
||||
// once SIGTERM is registered, a shutdown that hangs could not be interrupted by
|
||||
// anything short of SIGKILL.
|
||||
//
|
||||
// The hang is now a cleanup callback that never returns, which is where a
|
||||
// shutdown actually hangs, and it is reached through gracefulShutdown - so this
|
||||
// also pins where the disposition is restored. Restore it before the drain
|
||||
// window and the window itself becomes the interruptible part; restore it never
|
||||
// and this test hangs.
|
||||
func TestASecondSignalStillKillsAStuckShutdown(t *testing.T) {
|
||||
cmd, lines := startChild(t, true)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("first signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
// The child is now on its way into a cleanup that will not finish on its
|
||||
// own. Signalled repeatedly rather than once: the marker is printed just
|
||||
// before gracefulShutdown is entered, and the disposition is not restored
|
||||
// until the drain window is over - zero seconds here, but not zero
|
||||
// instructions - so a single signal sent immediately after the marker can
|
||||
// still land in the buffered channel and be dropped. Which of them does
|
||||
// the killing is not the assertion; that one of them can is.
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
retry := time.NewTicker(200 * time.Millisecond)
|
||||
defer retry.Stop()
|
||||
deadline := time.After(15 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("child exited cleanly; it was supposed to be killed by the second signal")
|
||||
}
|
||||
return
|
||||
case <-retry.C:
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("second signal: %v", err)
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("the second signal did not kill a stuck shutdown - the escape hatch is gone")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 21. srv.Shutdown reports an error exactly when connections were
|
||||
// still in flight, and the old code answered that with log.Fatal - an
|
||||
// unconditional os.Exit(1). Everything after it, which is where the cleanup
|
||||
// hooks will hang, never ran. A failed Shutdown must not end the process.
|
||||
func TestShutdownTimeoutDoesNotStopWhatFollows(t *testing.T) {
|
||||
cmd, lines := startChild(t, false, childHangConn+"=1")
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
seen, _ := await(t, lines, markerExiting, 20*time.Second)
|
||||
|
||||
var timedOut bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, "shutdown error:") {
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
if !timedOut {
|
||||
t.Fatalf("Shutdown did not time out, so this test proves nothing; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
var cleaned bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, markerCleanup) {
|
||||
cleaned = true
|
||||
}
|
||||
}
|
||||
if !cleaned {
|
||||
t.Fatalf("the BeforeExit callback did not run after a failed Shutdown; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v after a failed Shutdown, want a clean exit", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A callback that outlasts its budget must not take the process with it, and
|
||||
// must not be waited for: RunShutdown reports the deadline and returns, the
|
||||
// callback carries on, and the process still exits cleanly. This is the half of
|
||||
// the contract that is easy to get backwards - the context bounds the wait, not
|
||||
// the work, because Go cannot cancel a function that does not check for it.
|
||||
func TestACleanupThatOutlastsItsBudgetIsAbandonedNotAwaited(t *testing.T) {
|
||||
cmd, lines := startChild(t, false, childSlowCleanup+"=1")
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
// The budget is 300ms and the callback sleeps two seconds. If RunShutdown
|
||||
// waited for it, this marker would not arrive for two seconds; the one
|
||||
// second here is what makes "abandoned, not awaited" the thing asserted.
|
||||
seen, _ := await(t, lines, markerExiting, 1*time.Second)
|
||||
|
||||
var reported bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, "cleanup error:") {
|
||||
reported = true
|
||||
}
|
||||
if strings.Contains(l, markerCleanup) {
|
||||
t.Fatalf("the slow callback finished before the process moved on, so nothing was abandoned; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
}
|
||||
if !reported {
|
||||
t.Fatalf("RunShutdown returned no error for a callback that outlasted the budget; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit despite the abandoned callback", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The core acceptance: with a drain window configured, something outside the
|
||||
// process can observe that this instance is draining, on a connection it opens
|
||||
// after the signal, and can still be served while it does.
|
||||
//
|
||||
// Two windows rather than one. A single value proves only that something takes
|
||||
// that long, which a hard-coded sleep anywhere in the sequence would satisfy;
|
||||
// two say the wait is the configured one.
|
||||
//
|
||||
// What each answer is for:
|
||||
//
|
||||
// - /ready reporting "draining" is the window being observable at all. The
|
||||
// status code alone would not say it: with no database configured the
|
||||
// probe's own checks fail and 503 is also the answer before the signal.
|
||||
// - The server refusing to keep those connections alive is the window being
|
||||
// useful. It keeps them alive until Shutdown sets shuttingDown(), so
|
||||
// without switching keep-alive off here a balancer's pool would sit
|
||||
// untouched for the whole window and be cut at the end of it anyway. The
|
||||
// header saying so is Connection: close; the transport consumes it and
|
||||
// reports it as Response.Close, which is what a sample records.
|
||||
// - /health staying 200 is the window not asking to be restarted, and the
|
||||
// ordinary route staying 200 is the window not refusing work. Draining is
|
||||
// "stop sending me new work", not "reject what arrives".
|
||||
func TestTheDrainWindowIsObservableWhileStillServing(t *testing.T) {
|
||||
for _, drain := range []time.Duration{300 * time.Millisecond, 1200 * time.Millisecond} {
|
||||
t.Run(drain.String(), func(t *testing.T) {
|
||||
cmd, lines := startChild(t, false,
|
||||
fmt.Sprintf("%s=%d", childDrainMS, drain.Milliseconds()))
|
||||
addr := childAddr(t, lines)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
w := watch(addr, readyPath, healthPath, pingPath)
|
||||
// Long enough for a round of answers from a server that is not yet
|
||||
// draining, which is what the keep-alive assertion below compares
|
||||
// against.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
signalAt := time.Now()
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
|
||||
samples := w.wait(t, drain+30*time.Second)
|
||||
spent := took(t, lines, 10*time.Second)
|
||||
await(t, lines, markerExiting, 10*time.Second)
|
||||
|
||||
if spent < drain {
|
||||
t.Errorf("the shutdown took %s, want at least the %s window", spent, drain)
|
||||
}
|
||||
|
||||
var refusedAt = -1
|
||||
for i, s := range samples {
|
||||
if s.status == 0 {
|
||||
refusedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if refusedAt < 0 {
|
||||
t.Fatalf("the child never stopped accepting; saw:\n%s", describe(samples))
|
||||
}
|
||||
|
||||
var keptAliveBefore, drainingInside, closedInside bool
|
||||
for _, s := range samples[:refusedAt] {
|
||||
switch s.path {
|
||||
case readyPath:
|
||||
if s.at.Before(signalAt) && !s.draining && !s.willClose {
|
||||
keptAliveBefore = true
|
||||
}
|
||||
if s.at.After(signalAt) && s.draining {
|
||||
drainingInside = true
|
||||
if s.willClose {
|
||||
closedInside = true
|
||||
}
|
||||
}
|
||||
case healthPath, pingPath:
|
||||
if s.status != http.StatusOK {
|
||||
t.Errorf("%s answered %d before the listener closed, want 200;\n%s",
|
||||
s.path, s.status, describe(samples))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !keptAliveBefore {
|
||||
t.Fatalf("no answer before the signal kept the connection alive, so the header assertion below proves nothing;\n%s",
|
||||
describe(samples))
|
||||
}
|
||||
if !drainingInside {
|
||||
t.Errorf("no answer inside the window reported draining; the flip and the closed listener were not far enough apart to observe;\n%s",
|
||||
describe(samples))
|
||||
}
|
||||
if !closedInside {
|
||||
t.Errorf("answers inside the window still kept the connection alive, so a pooled connection survives the whole window and is cut at the end of it anyway;\n%s",
|
||||
describe(samples))
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The default has to be no window at all: a process that configures no
|
||||
// extend.shutdown section must shut down the way it did before the section
|
||||
// existed.
|
||||
//
|
||||
// Asserted as a sequence rather than as a duration. How long a shutdown takes
|
||||
// is decided by how much the cleanup callbacks have to do, so "as fast as
|
||||
// before" is not falsifiable; "nothing was inserted between the signal and the
|
||||
// listener closing" is.
|
||||
func TestAnUnconfiguredShutdownAddsNoWindow(t *testing.T) {
|
||||
cmd, lines := startChild(t, false)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
spent := took(t, lines, 10*time.Second)
|
||||
if spent > 100*time.Millisecond {
|
||||
t.Errorf("an unconfigured shutdown spent %s between the signal and exiting; "+
|
||||
"with no drain window and no cleanup callbacks it must be immediate", spent)
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A second signal during the window ends it early rather than killing the
|
||||
// process. Somebody sending another kill wants this over with sooner, and the
|
||||
// answer to that is to stop draining - not to skip the cleanup, which is what
|
||||
// the default disposition would do.
|
||||
//
|
||||
// This is the pair to TestASecondSignalStillKillsAStuckShutdown: the escape
|
||||
// hatch has to be closed for the length of the window and open after it.
|
||||
func TestASecondSignalEndsTheDrainWindowEarly(t *testing.T) {
|
||||
// Long enough that the shutdown cannot plausibly have taken this long on
|
||||
// its own, short enough that the test does not sit out the whole window
|
||||
// when the early exit is missing - it fails on the reported duration
|
||||
// instead of on a timeout, which says which of the two broke.
|
||||
const window = 10 * time.Second
|
||||
cmd, lines := startChild(t, false,
|
||||
fmt.Sprintf("%s=%d", childDrainMS, window.Milliseconds()))
|
||||
addr := childAddr(t, lines)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
w := watch(addr, readyPath)
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("first signal: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for !w.sawDraining() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the child never reported draining, so the second signal below would not land inside the window")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("second signal: %v", err)
|
||||
}
|
||||
|
||||
spent := took(t, lines, window+20*time.Second)
|
||||
if spent >= window {
|
||||
t.Errorf("the window ran its full %s despite a second signal (%s); the signal was ignored", window, spent)
|
||||
}
|
||||
await(t, lines, markerExiting, 10*time.Second)
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v; a second signal inside the window must end the window, not the process", err)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -49,7 +49,7 @@ func init() {
|
||||
rootCmd.AddCommand(app.StartCmd)
|
||||
}
|
||||
|
||||
//Execute : apply commands
|
||||
// Execute : apply commands
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(-1)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
// A mistyped --app used to be indistinguishable from an up-to-date database on
|
||||
// all three paths: `migrate` printed that the app was unknown and exited 0,
|
||||
// while `--dry-run` and `status` printed "nothing to apply" and "none
|
||||
// recorded" - the same words a database with nothing pending produces. An
|
||||
// operator scripting `migrate --app crmm && deploy` therefore deployed against
|
||||
// a database the migrations never touched.
|
||||
func TestAppRegistrationErrorRejectsAnUnknownCode(t *testing.T) {
|
||||
restore := appCode
|
||||
t.Cleanup(func() { appCode = restore })
|
||||
|
||||
appCode = "doesnotexist"
|
||||
err := appRegistrationError()
|
||||
if err == nil {
|
||||
t.Fatal("an unregistered app code must be an error, not an empty run")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"doesnotexist"`) {
|
||||
t.Errorf("the message must quote what was typed; got %q", err)
|
||||
}
|
||||
// Listing what is registered is what turns the error into a fix: the typo
|
||||
// is usually one letter away from something in this list.
|
||||
if !strings.Contains(err.Error(), migration.FrameworkAppCode) {
|
||||
t.Errorf("the message must list the registered codes; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRegistrationErrorAcceptsWhatIsRegistered(t *testing.T) {
|
||||
restore := appCode
|
||||
t.Cleanup(func() { appCode = restore })
|
||||
|
||||
for _, code := range []string{
|
||||
"", // no --app at all: every migration runs
|
||||
migration.FrameworkAppCode, // "core", the framework's own
|
||||
strings.ToUpper(migration.FrameworkAppCode), // codes normalize to lower case
|
||||
} {
|
||||
appCode = code
|
||||
if err := appRegistrationError(); err != nil {
|
||||
t.Errorf("appCode %q must be accepted; got %v", code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A deployment decides whether to start the new version on this command's
|
||||
// exit code. Before this batch the only failure that produced one was a
|
||||
// failing migration function, and it produced it by ending the process from
|
||||
// inside the migration engine; moving that out would have taken the last
|
||||
// reported failure with it.
|
||||
func TestExitOnErrorEndsTheCommandNonZero(t *testing.T) {
|
||||
var codes []int
|
||||
osExit = func(c int) { codes = append(codes, c) }
|
||||
t.Cleanup(func() { osExit = origExit })
|
||||
|
||||
var out bytes.Buffer
|
||||
exitOnError(&out, errors.New("the tenant database is unreachable"))
|
||||
|
||||
if len(codes) != 1 || codes[0] != 1 {
|
||||
t.Errorf("exit codes = %v, want [1]", codes)
|
||||
}
|
||||
if !strings.Contains(out.String(), "the tenant database is unreachable") {
|
||||
t.Errorf("the reason was not reported: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitOnErrorLetsSuccessThrough(t *testing.T) {
|
||||
var codes []int
|
||||
osExit = func(c int) { codes = append(codes, c) }
|
||||
t.Cleanup(func() { osExit = origExit })
|
||||
|
||||
var out bytes.Buffer
|
||||
exitOnError(&out, nil)
|
||||
|
||||
if len(codes) != 0 {
|
||||
t.Errorf("a successful migration exited with %v", codes)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("a successful migration wrote %q", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
// engine is the part of the migration engine the installer drives.
|
||||
//
|
||||
// An interface rather than *migration.Migration because the concrete type is
|
||||
// a package-level singleton with no exported constructor, so a test that took
|
||||
// it would be sharing one registry with every other test in the process.
|
||||
type engine interface {
|
||||
SetDb(*gorm.DB)
|
||||
Status() ([]migration.StatusEntry, error)
|
||||
MigrateApp(string) error
|
||||
}
|
||||
|
||||
// installReport is what an install did, for the command to print.
|
||||
type installReport struct {
|
||||
Code string
|
||||
// Version is the manifest version this run recorded.
|
||||
Version string
|
||||
// Previous is the version sys_app held before this run, empty when this
|
||||
// is the first install.
|
||||
Previous string
|
||||
// Applied lists the versions this run brought in, in the order they were
|
||||
// applied. Empty on a no-op, and also empty on a run that only corrected
|
||||
// sys_app - the difference is NoOp.
|
||||
Applied []string
|
||||
// NoOp says nothing was left to do: the app is recorded as installed, at
|
||||
// this same version, with no migration outstanding.
|
||||
NoOp bool
|
||||
}
|
||||
|
||||
// install brings one application up to the version its manifest declares.
|
||||
//
|
||||
// Three phases, each committing on its own. They are not one transaction and
|
||||
// cannot be: an application's versions are separate migration files, and a
|
||||
// DDL statement inside any of them commits the transaction around it on
|
||||
// MySQL, which destroys an outer transaction and every savepoint taken from
|
||||
// it. So this does not
|
||||
// promise that a half-installed application cannot happen. It promises that
|
||||
// one is visible when it does: phase A writes "installing" before anything
|
||||
// that can fail, and phase C turns that into "installed" or "failed".
|
||||
//
|
||||
// What is left to apply comes from sys_migration, never from sys_app.
|
||||
// sys_app is a derived view - a summary for a human, and the answer to "which
|
||||
// version does this app think it is at". If it were the authority, then an
|
||||
// operator who deleted sys_migration rows by hand would be told an app is
|
||||
// installed while its schema is not, which is worse than not knowing.
|
||||
func install(db *gorm.DB, eng engine, m app.Manifest) (installReport, error) {
|
||||
code := migration.NormalizeAppCode(m.Code)
|
||||
rep := installReport{Code: code, Version: m.Version}
|
||||
if code == "" {
|
||||
return rep, errors.New("the manifest declares no app code")
|
||||
}
|
||||
if code == migration.FrameworkAppCode {
|
||||
// Installing the framework is what `migrate` is, and the framework
|
||||
// has no manifest and no sys_app row. Saying so beats writing a row
|
||||
// that nothing else in this batch expects to exist.
|
||||
return rep, fmt.Errorf("%q is the framework's own migrations, not an application; run `migrate` for those", code)
|
||||
}
|
||||
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
|
||||
return rep, errors.New("sys_app does not exist; run `migrate` first to bring the framework's own tables up to date")
|
||||
}
|
||||
|
||||
eng.SetDb(db)
|
||||
|
||||
row, found, err := loadApp(db, code)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
// sameVersion is only meaningful when found; it stays false otherwise.
|
||||
// The comparison happens here, before phase A, so an unparseable
|
||||
// recorded version is refused while it is still readable rather than
|
||||
// after being overwritten.
|
||||
sameVersion := false
|
||||
if found {
|
||||
rep.Previous = row.Version
|
||||
cmp, err := app.Compare(m.Version, row.Version)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("comparing %s against the recorded %s: %w", m.Version, row.Version, err)
|
||||
}
|
||||
if cmp < 0 {
|
||||
return rep, fmt.Errorf("%s is recorded at %s; installing %s would be a downgrade, which is not supported",
|
||||
code, row.Version, m.Version)
|
||||
}
|
||||
sameVersion = cmp == 0
|
||||
}
|
||||
|
||||
if err := requiresInstalled(db, code, m); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
pending, err := pendingFor(eng, code)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// Nothing outstanding, recorded as installed, at this same version. All
|
||||
// three, and the first one comes from sys_migration: a row that says
|
||||
// installed while a migration of its has never run is exactly the case
|
||||
// sys_app must not be believed about. AppInstalling is not installed -
|
||||
// it is what a row reads as after the process was killed partway.
|
||||
if found && sameVersion && row.Status == adminmodels.AppInstalled && len(pending) == 0 {
|
||||
rep.NoOp = true
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// Phase A: the attempt is on disk before anything that can fail.
|
||||
now := time.Now()
|
||||
if err := beginInstall(db, &row, m, code, found, now); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// Phase B: no atomicity across these, by the nature of the thing.
|
||||
runErr := eng.MigrateApp(code)
|
||||
|
||||
// Phase C.
|
||||
if runErr != nil {
|
||||
failed := ""
|
||||
var vf *migration.VersionFailure
|
||||
if errors.As(runErr, &vf) {
|
||||
failed = vf.Version
|
||||
}
|
||||
if err := markFailed(db, code, failed, runErr, time.Now()); err != nil {
|
||||
return rep, errors.Join(runErr, fmt.Errorf("recording the failure on sys_app: %w", err))
|
||||
}
|
||||
return rep, runErr
|
||||
}
|
||||
if err := markInstalled(db, code, row.InstalledAt, time.Now()); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.Applied = pending
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// loadApp reads the sys_app row for code. A missing row is not an error: it
|
||||
// is what a first install looks like.
|
||||
func loadApp(db *gorm.DB, code string) (adminmodels.SysApp, bool, error) {
|
||||
var row adminmodels.SysApp
|
||||
err := db.Where("app_code = ?", code).First(&row).Error
|
||||
if err == nil {
|
||||
return row, true, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return adminmodels.SysApp{}, false, nil
|
||||
}
|
||||
return adminmodels.SysApp{}, false, fmt.Errorf("reading sys_app for %q: %w", code, err)
|
||||
}
|
||||
|
||||
// requiresInstalled refuses an install whose declared dependencies are not
|
||||
// installed, and names the ones that are not.
|
||||
//
|
||||
// It does not install them. "Install this application" would otherwise mean
|
||||
// "and everything it happens to name, and everything those name" - a blast
|
||||
// radius the operator did not ask for and cannot see before it happens. What
|
||||
// they get instead is a list and the order to do it in.
|
||||
//
|
||||
// An unfinished or failed dependency counts as missing, and says which it is:
|
||||
// "not installed" sends someone to install it, "did not finish" sends them to
|
||||
// look at why.
|
||||
func requiresInstalled(db *gorm.DB, code string, m app.Manifest) error {
|
||||
if len(m.Requires) == 0 {
|
||||
return nil
|
||||
}
|
||||
apps, err := loadApps(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var why, what []string
|
||||
for _, req := range m.Requires {
|
||||
want := migration.NormalizeAppCode(req)
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
switch row, ok := apps[want]; {
|
||||
case !ok:
|
||||
reason = "not installed"
|
||||
case row.Status == adminmodels.AppFailed:
|
||||
reason = "its install failed"
|
||||
case row.Status == adminmodels.AppInstalling:
|
||||
reason = "its install did not finish"
|
||||
case row.Status != adminmodels.AppInstalled:
|
||||
// A status this binary has no name for. Saying so beats the
|
||||
// catch-all this used to be, which read any future value as
|
||||
// "did not finish" - a sentence that would be wrong for
|
||||
// whatever reason the value was added.
|
||||
reason = fmt.Sprintf("its status is %d, which this binary does not recognise", row.Status)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
why = append(why, want+" ("+reason+")")
|
||||
what = append(what, want)
|
||||
}
|
||||
if len(why) > 0 {
|
||||
return fmt.Errorf("%s requires %s; install %s first",
|
||||
code, strings.Join(why, ", "), strings.Join(what, " and "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuseOnDependencyCycle reports a cycle anywhere in the registered
|
||||
// manifests, whether or not the application being installed is part of it.
|
||||
//
|
||||
// Over the whole set rather than one application's closure, because a cycle
|
||||
// between two applications neither of which is the one being installed is
|
||||
// still an authoring mistake, and finding it the day somebody happens to
|
||||
// install into it - with an error naming two applications they did not ask
|
||||
// for - is the worse time to find it.
|
||||
//
|
||||
// Requires naming an application that is not registered is not a cycle and
|
||||
// not reported here; that is requiresInstalled's answer to give, against the
|
||||
// database, at the time it matters.
|
||||
func refuseOnDependencyCycle(manifests map[string]app.Manifest) error {
|
||||
const (
|
||||
white = 0 // not visited
|
||||
grey = 1 // on the current path
|
||||
black = 2 // finished
|
||||
)
|
||||
colour := make(map[string]int, len(manifests))
|
||||
|
||||
codes := make([]string, 0, len(manifests))
|
||||
for code := range manifests {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
// Sorted, so the same set of manifests always reports the same cycle
|
||||
// rather than whichever one the map happened to hand over first.
|
||||
sort.Strings(codes)
|
||||
|
||||
var path []string
|
||||
var walk func(code string) error
|
||||
walk = func(code string) error {
|
||||
switch colour[code] {
|
||||
case grey:
|
||||
// Trim the path to where this code first appears, so the error
|
||||
// is the cycle and not the walk that reached it. grey is only
|
||||
// ever set together with the append below, and cleared together
|
||||
// with the matching trim, so the code is always on the path.
|
||||
cycle := append(slices.Clone(path[slices.Index(path, code):]), code)
|
||||
return fmt.Errorf("the declared dependencies form a cycle: %s",
|
||||
strings.Join(cycle, " -> "))
|
||||
case black:
|
||||
return nil
|
||||
}
|
||||
colour[code] = grey
|
||||
path = append(path, code)
|
||||
// In the order the manifest declared them, which is a fixed order
|
||||
// already - sorting here would only make the reported cycle harder
|
||||
// to line up against the manifest that caused it. The determinism
|
||||
// that matters comes from the sorted outer loop, because that one
|
||||
// walks a map.
|
||||
for _, r := range manifests[code].Requires {
|
||||
n := migration.NormalizeAppCode(r)
|
||||
if _, registered := manifests[n]; !registered {
|
||||
// Including the empty string, which Register rejects, so
|
||||
// no manifest is filed under it.
|
||||
continue
|
||||
}
|
||||
if err := walk(n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
path = path[:len(path)-1]
|
||||
colour[code] = black
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
if err := walk(code); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadApps reads every sys_app row, keyed by app code.
|
||||
//
|
||||
// A database that has never had 1786700007000 applied has no such table, and
|
||||
// that is not an error here: `migrate status` has to keep working on a
|
||||
// database that has not been migrated at all, which is when it is most wanted.
|
||||
// A nil map is the honest answer there, and the caller prints what it always
|
||||
// printed.
|
||||
func loadApps(db *gorm.DB) (map[string]adminmodels.SysApp, error) {
|
||||
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []adminmodels.SysApp
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("reading sys_app: %w", err)
|
||||
}
|
||||
out := make(map[string]adminmodels.SysApp, len(rows))
|
||||
for _, r := range rows {
|
||||
// An application cannot be filed under the empty code or the one
|
||||
// reserved for the framework - Register rejects both - so a row
|
||||
// carrying either was not written by an install. Dropping it here
|
||||
// is the one place that settles it: every reader of this map would
|
||||
// otherwise have to decide separately, and `migrate status` would
|
||||
// merge such a row into the framework's own group.
|
||||
if r.AppCode == "" || r.AppCode == migration.FrameworkAppCode {
|
||||
continue
|
||||
}
|
||||
out[r.AppCode] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pendingFor is the authoritative answer to "what is left to apply", and it
|
||||
// is recomputed every time rather than stored: what is registered in this
|
||||
// process, minus what sys_migration says has run. sys_app.failed_version is a
|
||||
// snapshot of what this returned once and may be stale by now; nothing may
|
||||
// read it to decide this.
|
||||
func pendingFor(eng engine, code string) ([]string, error) {
|
||||
entries, err := eng.Status()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.AppCode == code && e.Registered && !e.Applied {
|
||||
out = append(out, e.Version)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// beginInstall is phase A. It refreshes every descriptive column from the
|
||||
// manifest, because those are the manifest's to say and the row is only a
|
||||
// copy, and it clears the two diagnostic columns so a stale failure from a
|
||||
// previous attempt cannot be read as this one's.
|
||||
func beginInstall(db *gorm.DB, row *adminmodels.SysApp, m app.Manifest, code string, found bool, now time.Time) error {
|
||||
row.AppCode = code
|
||||
row.Name = m.Name
|
||||
row.Version = m.Version
|
||||
row.Description = m.Description
|
||||
row.Author = m.Author
|
||||
row.Requires = strings.Join(m.Requires, ",")
|
||||
row.Pricing = m.Pricing
|
||||
row.License = m.License
|
||||
row.Status = adminmodels.AppInstalling
|
||||
row.FailedVersion = ""
|
||||
row.LastError = ""
|
||||
row.UpdatedAt = now
|
||||
if !found {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return fmt.Errorf("recording the install attempt for %q: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := db.Save(row).Error; err != nil {
|
||||
return fmt.Errorf("recording the install attempt for %q: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markInstalled is the success half of phase C. installed_at is set once and
|
||||
// never moved: an upgrade keeps the time of the first install, which is what
|
||||
// the column is for.
|
||||
//
|
||||
// Computed here rather than with COALESCE so the statement is the same on all
|
||||
// four drivers this repository supports.
|
||||
func markInstalled(db *gorm.DB, code string, installedAt *time.Time, now time.Time) error {
|
||||
updates := map[string]any{
|
||||
"status": adminmodels.AppInstalled,
|
||||
"updated_at": now,
|
||||
}
|
||||
if installedAt == nil {
|
||||
updates["installed_at"] = now
|
||||
}
|
||||
err := db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording %q as installed: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markFailed is the other half. Both columns it writes are diagnostic text
|
||||
// for whoever reads the row; no code may branch on either one.
|
||||
func markFailed(db *gorm.DB, code, failedVersion string, cause error, now time.Time) error {
|
||||
updates := map[string]any{
|
||||
"status": adminmodels.AppFailed,
|
||||
"failed_version": truncate(failedVersion, 64),
|
||||
"last_error": truncate(cause.Error(), 255),
|
||||
"updated_at": now,
|
||||
}
|
||||
return db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error
|
||||
}
|
||||
|
||||
// truncate cuts s to at most n runes, not bytes: these columns are declared in
|
||||
// characters, and a message that is partly Chinese would otherwise be cut in
|
||||
// the middle of one and stored as an invalid sequence.
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// reportInstall prints what happened, and says that the data is in place but
|
||||
// the code is not.
|
||||
//
|
||||
// That last sentence is not a pleasantry. Go links its applications at build
|
||||
// time and Vite resolves its import globs at build time, so installing an
|
||||
// application writes its menus, its APIs and its permissions and cannot make
|
||||
// one line of its code run. An operator who is not told that sees the menus
|
||||
// appear and reasonably concludes the thing is live.
|
||||
func reportInstall(w io.Writer, rep installReport) {
|
||||
if rep.NoOp {
|
||||
fmt.Fprintf(w, "%s %s is already installed; nothing to do\n", rep.Code, rep.Version)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case rep.Previous == "":
|
||||
fmt.Fprintf(w, "installed %s %s\n", rep.Code, rep.Version)
|
||||
case rep.Previous == rep.Version:
|
||||
fmt.Fprintf(w, "brought %s %s the rest of the way\n", rep.Code, rep.Version)
|
||||
default:
|
||||
fmt.Fprintf(w, "upgraded %s from %s to %s\n", rep.Code, rep.Previous, rep.Version)
|
||||
}
|
||||
if len(rep.Applied) > 0 {
|
||||
fmt.Fprintf(w, "applied %d migration(s): %s\n", len(rep.Applied), strings.Join(rep.Applied, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(w, "no migration was outstanding; only sys_app was brought up to date")
|
||||
}
|
||||
fmt.Fprintln(w, "the database is up to date, but the application's code is not running yet:")
|
||||
fmt.Fprintln(w, "rebuild and restart the server before expecting its routes to answer.")
|
||||
}
|
||||
|
||||
// manifestFor finds the manifest an application registered for code.
|
||||
//
|
||||
// A code nothing registered is an error naming what is registered, for the
|
||||
// same reason exitUnlessAppRegistered exists: the alternative is telling an
|
||||
// operator who typed `install ordr` that there was nothing to do.
|
||||
// Takes the snapshot rather than reading it, so this lookup and the caller's
|
||||
// cycle check see the same set. Two calls to app.Snapshot() would also be two
|
||||
// deep copies of the registry for one install.
|
||||
func manifestFor(all map[string]app.Manifest, code string) (app.Manifest, error) {
|
||||
want := migration.NormalizeAppCode(code)
|
||||
if m, ok := all[want]; ok {
|
||||
return m, nil
|
||||
}
|
||||
codes := make([]string, 0, len(all))
|
||||
for c := range all {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
sort.Strings(codes)
|
||||
if len(codes) == 0 {
|
||||
// Worth its own sentence: no application is compiled into this
|
||||
// binary at all, which is a different thing from having typed the
|
||||
// wrong one of several.
|
||||
return app.Manifest{}, fmt.Errorf(
|
||||
"no application registers a manifest in this binary, so %q cannot be installed; "+
|
||||
"an application has to be compiled in before it can be installed", want)
|
||||
}
|
||||
return app.Manifest{}, fmt.Errorf("no application registers the code %q; registered: %s",
|
||||
want, strings.Join(codes, ", "))
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
func newInstallDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&adminmodels.SysApp{}); err != nil {
|
||||
t.Fatalf("automigrate sys_app: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// fakeEngine stands in for the migration engine. The real one is a
|
||||
// package-level singleton with no exported constructor, so a test taking it
|
||||
// would share one registry with every other test in this process.
|
||||
type fakeEngine struct {
|
||||
entries []migration.StatusEntry
|
||||
// failWith, when set, is what MigrateApp returns instead of applying.
|
||||
failWith error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeEngine) SetDb(*gorm.DB) {}
|
||||
|
||||
func (f *fakeEngine) Status() ([]migration.StatusEntry, error) {
|
||||
out := make([]migration.StatusEntry, len(f.entries))
|
||||
copy(out, f.entries)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEngine) MigrateApp(code string) error {
|
||||
f.calls = append(f.calls, code)
|
||||
if f.failWith != nil {
|
||||
return f.failWith
|
||||
}
|
||||
for i := range f.entries {
|
||||
if f.entries[i].AppCode == code && f.entries[i].Registered {
|
||||
f.entries[i].Applied = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orderManifest(version string) app.Manifest {
|
||||
return app.Manifest{
|
||||
Code: "order",
|
||||
Name: "Orders",
|
||||
Version: version,
|
||||
Description: "order management",
|
||||
Author: "go-admin",
|
||||
// No dependency by default: these tests are about installing, and a
|
||||
// declared requirement would make every one of them set up a second
|
||||
// application first. requiresInstalled has its own tests below.
|
||||
Requires: nil,
|
||||
Pricing: "free",
|
||||
License: "MIT",
|
||||
}
|
||||
}
|
||||
|
||||
// appRow writes one sys_app row: what another application looks like to the
|
||||
// installer, in whichever state the caller is testing against.
|
||||
func appRow(t *testing.T, db *gorm.DB, code string, status int) {
|
||||
t.Helper()
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: code, Name: code, Version: "1.0.0", Status: status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seeding %q with status %d: %v", code, status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRow(t *testing.T, db *gorm.DB, code string) adminmodels.SysApp {
|
||||
t.Helper()
|
||||
var row adminmodels.SysApp
|
||||
if err := db.Where("app_code = ?", code).First(&row).Error; err != nil {
|
||||
t.Fatalf("sys_app has no row for %q: %v", code, err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// A1: a first install records the app, at the version the manifest declares,
|
||||
// with every descriptive column copied from it.
|
||||
func TestInstallRecordsAFirstInstall(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
{Version: "order-1786800002000", AppCode: "order", Registered: true},
|
||||
{Version: "crm-1786800001000", AppCode: "crm", Registered: true},
|
||||
}}
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Error("a first install reported nothing to do")
|
||||
}
|
||||
if got, want := len(rep.Applied), 2; got != want {
|
||||
t.Errorf("applied %v, want %d versions", rep.Applied, want)
|
||||
}
|
||||
// Only this app's migrations, not every pending one in the process.
|
||||
if len(eng.calls) != 1 || eng.calls[0] != "order" {
|
||||
t.Errorf("MigrateApp calls = %v", eng.calls)
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
if row.Version != "1.0.0" {
|
||||
t.Errorf("version = %q", row.Version)
|
||||
}
|
||||
if row.InstalledAt == nil {
|
||||
t.Error("installed_at was not set")
|
||||
}
|
||||
if row.Name != "Orders" || row.Author != "go-admin" || row.Description != "order management" {
|
||||
t.Errorf("descriptive columns not copied from the manifest: %+v", row)
|
||||
}
|
||||
if row.Pricing != "free" || row.License != "MIT" {
|
||||
t.Errorf("the reserved fields were not carried through: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// A2: installing the same version again is a no-op, and says so.
|
||||
func TestInstallIsANoOpAtTheSameVersion(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
before := loadRow(t, db, "order")
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
if !rep.NoOp {
|
||||
t.Error("installing the same version again was not reported as a no-op")
|
||||
}
|
||||
if len(eng.calls) != 1 {
|
||||
t.Errorf("the engine was driven again: %v", eng.calls)
|
||||
}
|
||||
after := loadRow(t, db, "order")
|
||||
if !after.UpdatedAt.Equal(before.UpdatedAt) {
|
||||
t.Error("a no-op rewrote the row")
|
||||
}
|
||||
var n int64
|
||||
db.Model(&adminmodels.SysApp{}).Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("sys_app has %d rows, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A no-op is only a no-op when nothing is outstanding. A row that says
|
||||
// installed while a migration of its has never run is the case sys_app must
|
||||
// not be believed over sys_migration.
|
||||
func TestInstallRunsWhenTheRowSaysInstalledButAMigrationIsPending(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
// A second version of the same app appears - the app was rebuilt with
|
||||
// one more migration file, without its version changing.
|
||||
eng.entries = append(eng.entries, migration.StatusEntry{
|
||||
Version: "order-1786800002000", AppCode: "order", Registered: true,
|
||||
})
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Fatal("an outstanding migration was reported as nothing to do")
|
||||
}
|
||||
if len(rep.Applied) != 1 || rep.Applied[0] != "order-1786800002000" {
|
||||
t.Errorf("applied = %v", rep.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// A9: an upgrade is in place. installed_at is the first install's, not this
|
||||
// one's.
|
||||
func TestInstallUpgradesInPlaceAndKeepsTheFirstInstallTime(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
first := loadRow(t, db, "order")
|
||||
if first.InstalledAt == nil {
|
||||
t.Fatal("installed_at was not set by the first install")
|
||||
}
|
||||
|
||||
eng.entries = append(eng.entries, migration.StatusEntry{
|
||||
Version: "order-1786800002000", AppCode: "order", Registered: true,
|
||||
})
|
||||
rep, err := install(db, eng, orderManifest("2.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
if rep.Previous != "1.0.0" {
|
||||
t.Errorf("previous = %q, want 1.0.0", rep.Previous)
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Version != "2.0.0" {
|
||||
t.Errorf("version = %q, want 2.0.0", row.Version)
|
||||
}
|
||||
if row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
if !row.InstalledAt.Equal(*first.InstalledAt) {
|
||||
t.Errorf("installed_at moved from %v to %v; an upgrade keeps the first install's time",
|
||||
first.InstalledAt, row.InstalledAt)
|
||||
}
|
||||
}
|
||||
|
||||
// A10: a downgrade is refused, and refused before anything is written.
|
||||
func TestInstallRefusesADowngrade(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("2.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
before := loadRow(t, db, "order")
|
||||
|
||||
_, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("a downgrade was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "downgrade") {
|
||||
t.Errorf("error = %q, it has to say what it refused", err)
|
||||
}
|
||||
after := loadRow(t, db, "order")
|
||||
if after.Version != before.Version || after.Status != before.Status {
|
||||
t.Errorf("the refused downgrade still wrote to the row: %+v -> %+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// A5: a failing migration leaves a row that says so, and says where.
|
||||
func TestInstallRecordsAFailure(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
boom := errors.New("the seed hit a duplicate")
|
||||
eng := &fakeEngine{
|
||||
entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
},
|
||||
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: boom},
|
||||
}
|
||||
|
||||
_, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("a failed install reported success")
|
||||
}
|
||||
if !errors.Is(err, boom) {
|
||||
t.Errorf("the cause is not reachable: %v", err)
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppFailed {
|
||||
t.Errorf("status = %d, want failed", row.Status)
|
||||
}
|
||||
if row.FailedVersion != "order-1786800001000" {
|
||||
t.Errorf("failed_version = %q", row.FailedVersion)
|
||||
}
|
||||
if !strings.Contains(row.LastError, "duplicate") {
|
||||
t.Errorf("last_error = %q", row.LastError)
|
||||
}
|
||||
if row.InstalledAt != nil {
|
||||
t.Error("installed_at was set by an install that failed")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed install is retried by running it again - not by any special
|
||||
// command, and without the previous attempt's diagnostics surviving into a
|
||||
// row that now says installed.
|
||||
func TestInstallResumesAfterAFailure(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{
|
||||
entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
},
|
||||
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: errors.New("boom")},
|
||||
}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err == nil {
|
||||
t.Fatal("the first attempt did not fail")
|
||||
}
|
||||
|
||||
eng.failWith = nil
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Error("a failed row was treated as installed")
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
if row.FailedVersion != "" || row.LastError != "" {
|
||||
t.Errorf("the previous failure survived onto a row that now says installed: %q / %q",
|
||||
row.FailedVersion, row.LastError)
|
||||
}
|
||||
if row.InstalledAt == nil {
|
||||
t.Error("installed_at was not set by the attempt that succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// A row stuck at installing - the process was killed partway - is not
|
||||
// installed, and must not be mistaken for it.
|
||||
func TestInstallRetriesARowStuckAtInstalling(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: "order", Name: "Orders", Version: "1.0.0",
|
||||
Status: adminmodels.AppInstalling,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
|
||||
}}
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Fatal("a row stuck at installing was reported as already installed")
|
||||
}
|
||||
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRejectsTheFrameworkCode(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
m := orderManifest("1.0.0")
|
||||
m.Code = migration.FrameworkAppCode
|
||||
_, err := install(db, &fakeEngine{}, m)
|
||||
if err == nil {
|
||||
t.Fatal("the framework was installed as an application")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "migrate") {
|
||||
t.Errorf("error = %q, it should point at the command that does this", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRefusesAnUnparseableRecordedVersion(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: "order", Name: "Orders", Version: "v1.0", Status: adminmodels.AppInstalled,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
_, err := install(db, &fakeEngine{}, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("an unparseable recorded version was compared anyway")
|
||||
}
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppInstalled || row.Version != "v1.0" {
|
||||
t.Errorf("the row was overwritten before the comparison failed: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// A7: the report has to say the code is not running yet. Menus appearing is
|
||||
// exactly what makes an operator think it is.
|
||||
func TestReportInstallSaysTheCodeIsNotRunningYet(t *testing.T) {
|
||||
var out strings.Builder
|
||||
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", Applied: []string{"order-1786800001000"}})
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "rebuild") || !strings.Contains(got, "restart") {
|
||||
t.Errorf("the report does not say the binary has to be rebuilt: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "order-1786800001000") {
|
||||
t.Errorf("the report does not name what it applied: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportInstallOnANoOp(t *testing.T) {
|
||||
var out strings.Builder
|
||||
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", NoOp: true})
|
||||
if !strings.Contains(out.String(), "already installed") {
|
||||
t.Errorf("output = %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// last_error is a varchar(255) declared in characters. A message that is
|
||||
// partly Chinese would be cut mid-rune by a byte-wise truncation and stored
|
||||
// as an invalid sequence.
|
||||
func TestTruncateCutsRunesNotBytes(t *testing.T) {
|
||||
s := strings.Repeat("迁", 300)
|
||||
got := truncate(s, 255)
|
||||
if n := len([]rune(got)); n != 255 {
|
||||
t.Errorf("kept %d runes, want 255", n)
|
||||
}
|
||||
if !strings.HasPrefix(s, got) {
|
||||
t.Error("truncation did not cut at a rune boundary")
|
||||
}
|
||||
if short := truncate("ok", 255); short != "ok" {
|
||||
t.Errorf("a short message was altered: %q", short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallNeedsSysApp(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
_, err = install(db, &fakeEngine{}, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("install ran against a database with no sys_app")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "migrate") {
|
||||
t.Errorf("error = %q, it should say what to run first", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The code written to sys_app and handed to the engine is the normalized one.
|
||||
// A manifest whose Code was typed with different case or stray spaces has to
|
||||
// land on the same identity migration.ForApp and seed.SeedMenus already use,
|
||||
// or the row and the migrations it stands for are filed under two names.
|
||||
func TestInstallNormalizesTheAppCode(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Code = " Order "
|
||||
|
||||
rep, err := install(db, eng, m)
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.Code != "order" {
|
||||
t.Errorf("reported code = %q, want order", rep.Code)
|
||||
}
|
||||
if len(eng.calls) != 1 || eng.calls[0] != "order" {
|
||||
t.Errorf("the engine was asked for %v, want [order]", eng.calls)
|
||||
}
|
||||
// The row has to be findable by the normalized code, which is what every
|
||||
// other table in this batch is keyed by.
|
||||
row := loadRow(t, db, "order")
|
||||
if row.AppCode != "order" {
|
||||
t.Errorf("app_code = %q", row.AppCode)
|
||||
}
|
||||
if len(rep.Applied) != 1 {
|
||||
t.Errorf("applied = %v; the normalized code has to match what Status reports", rep.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest's dependency list is stored as it was declared, in the CSV
|
||||
// shape sys_app.requires carries.
|
||||
func TestInstallStoresTheDeclaredRequires(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
appRow(t, db, "crm", adminmodels.AppInstalled)
|
||||
appRow(t, db, "billing", adminmodels.AppInstalled)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm", "billing"}
|
||||
|
||||
if _, err := install(db, eng, m); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if row := loadRow(t, db, "order"); row.Requires != "crm,billing" {
|
||||
t.Errorf("requires = %q, want the manifest's list as CSV", row.Requires)
|
||||
}
|
||||
}
|
||||
|
||||
// An application is not installed for you because something else names it.
|
||||
// "Install this" would otherwise mean "and everything it happens to name, and
|
||||
// everything those name".
|
||||
func TestInstallRefusesWhenADependencyIsNotInstalled(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm"}
|
||||
|
||||
_, err := install(db, eng, m)
|
||||
if err == nil {
|
||||
t.Fatal("an application with an uninstalled dependency was installed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "crm") || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Errorf("error = %q, it has to name what is missing and why", err)
|
||||
}
|
||||
if len(eng.calls) != 0 {
|
||||
t.Errorf("the engine ran anyway: %v", eng.calls)
|
||||
}
|
||||
// Refused before phase A, so a refusal leaves nothing behind.
|
||||
if n := count(t, db, "sys_app", "app_code = ?", "order"); n != 0 {
|
||||
t.Errorf("a refused install wrote %d sys_app row(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A dependency whose own install failed or never finished is not a dependency
|
||||
// that is there, and the two say which they are - one sends you to install it,
|
||||
// the other to look at why.
|
||||
func TestInstallRefusesWhenADependencyIsNotFinished(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{"failed", adminmodels.AppFailed, "its install failed"},
|
||||
{"installing", adminmodels.AppInstalling, "did not finish"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
appRow(t, db, "crm", tc.status)
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm"}
|
||||
_, err := install(db, &fakeEngine{}, m)
|
||||
if err == nil {
|
||||
t.Fatal("the dependency was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("error = %q, want it to say %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAcceptsASatisfiedDependency(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
appRow(t, db, "crm", adminmodels.AppInstalled)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm"}
|
||||
|
||||
if _, err := install(db, eng, m); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyCycleIsRefused(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"a": {Code: "a", Requires: []string{"b"}},
|
||||
"b": {Code: "b", Requires: []string{"c"}},
|
||||
"c": {Code: "c", Requires: []string{"a"}},
|
||||
}
|
||||
err := refuseOnDependencyCycle(manifests)
|
||||
if err == nil {
|
||||
t.Fatal("a cycle was accepted")
|
||||
}
|
||||
// The error is the cycle, not the walk that reached it.
|
||||
if !strings.Contains(err.Error(), "a -> b -> c -> a") {
|
||||
t.Errorf("error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A cycle between two applications neither of which is being installed is
|
||||
// still an authoring mistake, and the day somebody installs into it is the
|
||||
// worse time to find out.
|
||||
func TestDependencyCycleIsRefusedEvenAwayFromTheTarget(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"order": {Code: "order"},
|
||||
"x": {Code: "x", Requires: []string{"y"}},
|
||||
"y": {Code: "y", Requires: []string{"x"}},
|
||||
}
|
||||
if err := refuseOnDependencyCycle(manifests); err == nil {
|
||||
t.Fatal("a cycle away from the target was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyGraphWithoutACycle(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"a": {Code: "a", Requires: []string{"b", "c"}},
|
||||
"b": {Code: "b", Requires: []string{"c"}},
|
||||
"c": {Code: "c"},
|
||||
// Naming something that is not registered is not a cycle. Whether it
|
||||
// is installed is a question for the database, at install time.
|
||||
"d": {Code: "d", Requires: []string{"nowhere"}},
|
||||
}
|
||||
if err := refuseOnDependencyCycle(manifests); err != nil {
|
||||
t.Errorf("a graph with no cycle was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An application that names itself.
|
||||
func TestDependencyCycleOfOne(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{"a": {Code: "a", Requires: []string{"a"}}}
|
||||
err := refuseOnDependencyCycle(manifests)
|
||||
if err == nil {
|
||||
t.Fatal("an application requiring itself was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "a -> a") {
|
||||
t.Errorf("error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The cycle reached from outside it. a is not part of anything circular; b
|
||||
// and c are. Reporting the walk instead of the cycle would name a as well,
|
||||
// and sending somebody to look at an application that is not involved is
|
||||
// the whole reason the path is trimmed.
|
||||
func TestDependencyCycleReportsOnlyTheCycleItReached(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"a": {Code: "a", Requires: []string{"b"}},
|
||||
"b": {Code: "b", Requires: []string{"c"}},
|
||||
"c": {Code: "c", Requires: []string{"b"}},
|
||||
}
|
||||
err := refuseOnDependencyCycle(manifests)
|
||||
if err == nil {
|
||||
t.Fatal("a cycle was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "b -> c -> b") {
|
||||
t.Errorf("error = %q, want just the cycle", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "a ->") {
|
||||
t.Errorf("the walk that reached the cycle was reported as part of it: %q", err)
|
||||
}
|
||||
}
|
||||
+370
-17
@@ -1,21 +1,46 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
var Migrate = &Migration{
|
||||
version: make(map[string]func(db *gorm.DB, version string) error),
|
||||
var Migrate = newMigration()
|
||||
|
||||
// contractSnapshot is contractmigration.Snapshot, indirected through a
|
||||
// package-level variable so tests can substitute an isolated
|
||||
// *contractmigration.Registry's Snapshot instead of reaching into
|
||||
// go-admin-core's single process-wide registry, which every *Migration in
|
||||
// this process - test-local or the package-level Migrate - reads through the
|
||||
// same call. See mergedEntries.
|
||||
var contractSnapshot = contractmigration.Snapshot
|
||||
|
||||
func newMigration() *Migration {
|
||||
return &Migration{version: make(map[string]versionEntry)}
|
||||
}
|
||||
|
||||
// versionEntry is one registered migration plus the app it belongs to. The
|
||||
// empty app code means the framework itself, which is also what the
|
||||
// sys_migration.app_code column defaults to, so history written before this
|
||||
// field existed reads back correctly with no backfill.
|
||||
type versionEntry struct {
|
||||
appCode string
|
||||
fn func(db *gorm.DB, version string) error
|
||||
}
|
||||
|
||||
type Migration struct {
|
||||
db *gorm.DB
|
||||
version map[string]func(db *gorm.DB, version string) error
|
||||
version map[string]versionEntry
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
@@ -27,27 +52,347 @@ func (e *Migration) SetDb(db *gorm.DB) {
|
||||
e.db = db
|
||||
}
|
||||
|
||||
// SetVersion registers a migration owned by the framework. Signature and
|
||||
// behaviour are unchanged: every existing call site in version/*.go keeps
|
||||
// compiling and keeps writing common.Migration{Version: version} with no app
|
||||
// code, which is the correct meaning of "framework".
|
||||
func (e *Migration) SetVersion(k string, f func(db *gorm.DB, version string) error) {
|
||||
e.mutex.Lock()
|
||||
defer e.mutex.Unlock()
|
||||
e.version[k] = f
|
||||
e.setVersion(k, "", f)
|
||||
}
|
||||
|
||||
func (e *Migration) Migrate() {
|
||||
versions := make([]string, 0)
|
||||
for k := range e.version {
|
||||
func (e *Migration) setVersion(k, appCode string, f func(db *gorm.DB, version string) error) {
|
||||
e.mutex.Lock()
|
||||
defer e.mutex.Unlock()
|
||||
e.version[k] = versionEntry{appCode: appCode, fn: f}
|
||||
}
|
||||
|
||||
// AppMigrationFunc is the signature of a migration registered through ForApp.
|
||||
//
|
||||
// It receives appCode explicitly because the migration - not the framework -
|
||||
// writes its own completion row, normally as the last statement inside its own
|
||||
// transaction. That is what makes "the schema change and the record of it
|
||||
// commit together" true, and the framework cannot insert the row on the
|
||||
// migration's behalf without giving that up. Handing the code to the function
|
||||
// is what stops an app's migrations from silently recording themselves as the
|
||||
// framework's.
|
||||
type AppMigrationFunc func(db *gorm.DB, version, appCode string) error
|
||||
|
||||
// AppRegistrar is a per-app view over a registry.
|
||||
type AppRegistrar struct {
|
||||
m *Migration
|
||||
appCode string
|
||||
}
|
||||
|
||||
// FrameworkAppCode is the name migrate status prints for migrations that belong
|
||||
// to the framework rather than to an app, and the name --app accepts to select
|
||||
// them. The stored app code for those is the empty string; this is only the
|
||||
// spelling humans use. It is reserved - ForApp rejects it - so that every group
|
||||
// heading status prints is also a value --app understands.
|
||||
const FrameworkAppCode = "core"
|
||||
|
||||
// ForApp returns a registrar that records migrations under code.
|
||||
//
|
||||
// The code is lower-cased: sys_migration.version sorts as ASCII, so mixed case
|
||||
// would order MyApp before crm for no reason a reader could guess, and the two
|
||||
// spellings would group as two different apps in migrate status.
|
||||
//
|
||||
// An empty or reserved code panics rather than falling back to the framework.
|
||||
// Registration happens in init(), so this fires the first time the binary runs
|
||||
// anywhere, which is the point: an app whose migrations quietly file themselves
|
||||
// under the framework is exactly the class of silent failure this work is meant
|
||||
// to remove. Framework migrations call Migrate.SetVersion directly.
|
||||
func ForApp(code string) *AppRegistrar { return Migrate.ForApp(code) }
|
||||
|
||||
// ForApp is the same on an explicit registry, which is what tests use.
|
||||
func (e *Migration) ForApp(code string) *AppRegistrar {
|
||||
code = NormalizeAppCode(code)
|
||||
switch code {
|
||||
case "":
|
||||
panic("migration.ForApp: empty app code; framework migrations use Migrate.SetVersion")
|
||||
case FrameworkAppCode:
|
||||
panic("migration.ForApp: app code " + FrameworkAppCode + " is reserved for the framework")
|
||||
}
|
||||
return &AppRegistrar{m: e, appCode: code}
|
||||
}
|
||||
|
||||
// AppCode reports the code this registrar files migrations under, after
|
||||
// normalisation.
|
||||
func (r *AppRegistrar) AppCode() string { return r.appCode }
|
||||
|
||||
// SetVersion registers an app-owned migration under k, which is the bare
|
||||
// timestamp taken from the file name exactly as framework migrations do.
|
||||
//
|
||||
// What reaches sys_migration.version is the namespaced form; the version string
|
||||
// handed to f is that same namespaced string, so a migration that writes
|
||||
// common.Migration{Version: version, AppCode: appCode} records the key the
|
||||
// registry will look for next time.
|
||||
func (r *AppRegistrar) SetVersion(k string, f AppMigrationFunc) {
|
||||
key := namespacedKey(r.appCode, k)
|
||||
r.m.setVersion(key, r.appCode, func(db *gorm.DB, version string) error {
|
||||
return f(db, version, r.appCode)
|
||||
})
|
||||
}
|
||||
|
||||
// namespacedKey scopes k to appCode so two apps cannot collide on the
|
||||
// sys_migration.version primary key by minting the same millisecond timestamp.
|
||||
// Framework migrations (appCode == "") stay bare, matching every version string
|
||||
// already in production.
|
||||
func namespacedKey(appCode, k string) string {
|
||||
if appCode == "" {
|
||||
return k
|
||||
}
|
||||
return appCode + "-" + k
|
||||
}
|
||||
|
||||
// mergedEntries returns every migration this process knows about: the
|
||||
// host's own registry (e.version, filled by version/*.go and
|
||||
// version-local/*.go through SetVersion/ForApp) plus whatever a third-party
|
||||
// application registered through go-admin-core's sdk/contract/migration
|
||||
// package (PRD 006, F9's host wiring).
|
||||
//
|
||||
// That package keeps its own process-wide registry, entirely separate from
|
||||
// e.version, because a third-party application cannot reach into this
|
||||
// process to call an unexported method on *Migration - contract/migration's
|
||||
// package-level ForApp/Snapshot are the only door open to it. Without this
|
||||
// merge, migrate/status/--dry-run would only ever see the host's own
|
||||
// migrations: an application's ForApp("crm").SetVersion(...) would compile,
|
||||
// register successfully into contract/migration's registry, and then never
|
||||
// run, with no error anywhere - the exact silent gap this method closes.
|
||||
//
|
||||
// Entry and versionEntry are structurally identical (an app code plus a
|
||||
// func(db, version) error); the conversion below exists only because they
|
||||
// are two distinct named types, one per package, not because the data
|
||||
// differs.
|
||||
func (e *Migration) mergedEntries() map[string]versionEntry {
|
||||
e.mutex.Lock()
|
||||
out := make(map[string]versionEntry, len(e.version))
|
||||
for k, v := range e.version {
|
||||
out[k] = v
|
||||
}
|
||||
e.mutex.Unlock()
|
||||
|
||||
for k, entry := range contractSnapshot() {
|
||||
if _, exists := out[k]; exists {
|
||||
// contract/migration.ForApp namespaces every app-owned key as
|
||||
// appCode + "-" + k, and appCode is reserved from ""/"core", so
|
||||
// this should never collide with a host-registered key. If it
|
||||
// somehow does, the host's own registration wins rather than
|
||||
// silently overwriting it.
|
||||
continue
|
||||
}
|
||||
out[k] = versionEntry{appCode: entry.AppCode, fn: entry.Fn}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredVersions returns every migration version this binary registers,
|
||||
// sorted, without touching a database.
|
||||
//
|
||||
// Status answers a richer question - what is registered, what is applied, and
|
||||
// what is applied while nothing registers it - and needs a database to do it.
|
||||
// This is the half that can be asked of the process alone, which is what a
|
||||
// readiness check needs: the check holds the databases it is asking about, and
|
||||
// reusing Status would mean calling SetDb from a request handler, writing this
|
||||
// package's shared state from a request path.
|
||||
func (e *Migration) RegisteredVersions() []string {
|
||||
all := e.mergedEntries()
|
||||
out := make([]string, 0, len(all))
|
||||
for k := range all {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredVersions reports what the process-wide registry holds.
|
||||
func RegisteredVersions() []string { return Migrate.RegisteredVersions() }
|
||||
|
||||
// StatusEntry is one row of migrate status.
|
||||
type StatusEntry struct {
|
||||
AppCode string
|
||||
Version string
|
||||
Registered bool
|
||||
Applied bool
|
||||
ApplyTime *time.Time
|
||||
}
|
||||
|
||||
// Status merges the in-process registry with sys_migration, so it reports all
|
||||
// three shapes at once: registered but not applied, registered and applied, and
|
||||
// applied while nothing registers it any more - a row left behind by a
|
||||
// migration file that was deleted, or by an app that was uninstalled.
|
||||
//
|
||||
// It only reads. Nothing here creates or alters a table, which is what lets
|
||||
// both `status` and `--dry-run` run against a database without touching it.
|
||||
func (e *Migration) Status() ([]StatusEntry, error) {
|
||||
if e.db == nil {
|
||||
return nil, fmt.Errorf("migration: no database configured")
|
||||
}
|
||||
|
||||
all := e.mergedEntries()
|
||||
registered := make(map[string]string, len(all))
|
||||
for k, v := range all {
|
||||
registered[k] = v.appCode
|
||||
}
|
||||
|
||||
applied := make(map[string]common.Migration)
|
||||
// A database that has never been migrated has no sys_migration table.
|
||||
// Reporting everything as pending is the honest answer there; erroring out
|
||||
// would make status useless in exactly the case it is most wanted.
|
||||
if e.db.Migrator().HasTable(&common.Migration{}) {
|
||||
var rows []common.Migration
|
||||
if err := e.db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
applied[r.Version] = r
|
||||
}
|
||||
}
|
||||
|
||||
versions := make(map[string]struct{}, len(registered)+len(applied))
|
||||
for k := range registered {
|
||||
versions[k] = struct{}{}
|
||||
}
|
||||
for k := range applied {
|
||||
versions[k] = struct{}{}
|
||||
}
|
||||
list := make([]string, 0, len(versions))
|
||||
for k := range versions {
|
||||
list = append(list, k)
|
||||
}
|
||||
sort.Strings(list)
|
||||
|
||||
out := make([]StatusEntry, 0, len(list))
|
||||
for _, v := range list {
|
||||
entry := StatusEntry{Version: v}
|
||||
if code, ok := registered[v]; ok {
|
||||
entry.Registered = true
|
||||
entry.AppCode = code
|
||||
}
|
||||
if row, ok := applied[v]; ok {
|
||||
entry.Applied = true
|
||||
t := row.ApplyTime
|
||||
entry.ApplyTime = &t
|
||||
if !entry.Registered {
|
||||
// Nothing registers this version any more, so the database is
|
||||
// the only source left for what it belonged to.
|
||||
entry.AppCode = row.AppCode
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Migrate applies every registered migration that has not been applied yet,
|
||||
// across all apps.
|
||||
func (e *Migration) Migrate() error { return e.run(allApps) }
|
||||
|
||||
// MigrateApp applies only the migrations registered under appCode. Pass
|
||||
// FrameworkAppCode for the framework's own migrations.
|
||||
func (e *Migration) MigrateApp(appCode string) error { return e.run(AppFilter(appCode)) }
|
||||
|
||||
// VersionFailure names the migration that failed.
|
||||
//
|
||||
// The caller that needs this is an installer recording which version an
|
||||
// install got stuck on. That is a diagnostic snapshot and nothing more: the
|
||||
// authoritative answer to "where does a retry resume" is always recomputed
|
||||
// by subtracting sys_migration's applied rows from what is registered, never
|
||||
// read back from anywhere it was stored. Which is exactly why this carries
|
||||
// the version rather than leaving the caller to infer it - inferring it
|
||||
// would produce "what is pending now", a different question that happens to
|
||||
// have the same answer most of the time.
|
||||
type VersionFailure struct {
|
||||
Version string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *VersionFailure) Error() string {
|
||||
return fmt.Sprintf("migration %s failed: %v", e.Version, e.Err)
|
||||
}
|
||||
|
||||
func (e *VersionFailure) Unwrap() error { return e.Err }
|
||||
|
||||
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
|
||||
// command line matches one written in an init().
|
||||
func NormalizeAppCode(code string) string {
|
||||
return strings.ToLower(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// AppFilter turns a code as typed into the code stored in the registry, so
|
||||
// "core" selects the framework's migrations, whose stored code is empty.
|
||||
func AppFilter(code string) string {
|
||||
code = NormalizeAppCode(code)
|
||||
if code == FrameworkAppCode {
|
||||
return ""
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// DisplayAppCode is the inverse: what to print for a stored code.
|
||||
func DisplayAppCode(code string) string {
|
||||
if code == "" {
|
||||
return FrameworkAppCode
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// AppCodes lists the app codes with at least one registered migration, framework
|
||||
// included under its display name, sorted.
|
||||
func (e *Migration) AppCodes() []string {
|
||||
all := e.mergedEntries()
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range all {
|
||||
seen[DisplayAppCode(v.appCode)] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for code := range seen {
|
||||
out = append(out, code)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// run applies the pending migrations selected by appCode.
|
||||
//
|
||||
// It reports failure instead of ending the process. It used to call
|
||||
// log.Fatalf, which took the whole process down at the first failing
|
||||
// migration - so a caller had nowhere to record what happened, and a test
|
||||
// could not exercise a failing migration at all without killing the test
|
||||
// binary. The exit now lives at the command layer, where the exit code is
|
||||
// the command's business (see initDB in cmd/migrate/server.go).
|
||||
func (e *Migration) run(appCode string) error {
|
||||
all := e.mergedEntries()
|
||||
versions := make([]string, 0, len(all))
|
||||
entries := make(map[string]versionEntry, len(all))
|
||||
for k, v := range all {
|
||||
if appCode != allApps && v.appCode != appCode {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, k)
|
||||
entries[k] = v
|
||||
}
|
||||
if !sort.StringsAreSorted(versions) {
|
||||
sort.Strings(versions)
|
||||
sort.Strings(versions)
|
||||
|
||||
// A mistyped --app would otherwise select nothing and report "no
|
||||
// migrations to apply", which reads exactly like "already up to date".
|
||||
//
|
||||
// The command layer rejects an unregistered code before any database
|
||||
// work (exitUnlessAppRegistered), so on that path this is unreachable.
|
||||
// It is reachable from an installer, which asks for one app by name and
|
||||
// must not be told that installing an app nothing registered succeeded.
|
||||
if appCode != allApps && len(versions) == 0 {
|
||||
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
|
||||
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
|
||||
}
|
||||
|
||||
var err error
|
||||
var count int64
|
||||
applied := 0
|
||||
for _, v := range versions {
|
||||
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("checking whether migration %s was applied: %w", v, err)
|
||||
}
|
||||
if count > 0 {
|
||||
// Already applied. This used to print the bare count, so a mature
|
||||
@@ -56,8 +401,8 @@ func (e *Migration) Migrate() {
|
||||
continue
|
||||
}
|
||||
log.Printf("applying migration %s", v)
|
||||
if err = (e.version[v])(e.db.Debug(), v); err != nil {
|
||||
log.Fatalf("migration %s failed: %v", v, err)
|
||||
if err = entries[v].fn(e.db.Debug(), v); err != nil {
|
||||
return &VersionFailure{Version: v, Err: err}
|
||||
}
|
||||
applied++
|
||||
}
|
||||
@@ -66,9 +411,17 @@ func (e *Migration) Migrate() {
|
||||
} else {
|
||||
log.Printf("applied %d migration(s)", applied)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
|
||||
// from the empty app code, which selects the framework's own migrations.
|
||||
const allApps = "\x00all"
|
||||
|
||||
// GetFilename derives a migration's version from its file name. The rule
|
||||
// lives in contract/migration, because an application registering through
|
||||
// that package names its files by the same convention and must land on the
|
||||
// same version string; a second copy here is a second thing to keep in step.
|
||||
func GetFilename(s string) string {
|
||||
s = filepath.Base(s)
|
||||
return s[:13]
|
||||
return contractmigration.GetFilename(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// withContractRegistry points contractSnapshot at an isolated
|
||||
// *contractmigration.Registry for the duration of one test, instead of
|
||||
// go-admin-core's single process-wide one - see contractSnapshot's doc
|
||||
// comment for why that indirection exists. Restored on cleanup so other
|
||||
// tests in this package keep seeing an empty contract registry regardless of
|
||||
// run order.
|
||||
func withContractRegistry(t *testing.T) *contractmigration.Registry {
|
||||
t.Helper()
|
||||
reg := contractmigration.NewRegistry()
|
||||
orig := contractSnapshot
|
||||
contractSnapshot = reg.Snapshot
|
||||
t.Cleanup(func() { contractSnapshot = orig })
|
||||
return reg
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// recordFor is what an app's migration is expected to do: write its own
|
||||
// completion row, with the version it was handed and the app code it was told
|
||||
// it belongs to.
|
||||
func recordFor(db *gorm.DB, version, appCode string) error {
|
||||
return db.Create(&common.Migration{Version: version, AppCode: appCode}).Error
|
||||
}
|
||||
|
||||
func rowsByVersion(t *testing.T, db *gorm.DB) map[string]common.Migration {
|
||||
t.Helper()
|
||||
var rows []common.Migration
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read sys_migration: %v", err)
|
||||
}
|
||||
out := make(map[string]common.Migration, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.Version] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Acceptance 9: a migration registered through ForApp("x") lands in
|
||||
// sys_migration with app_code "x".
|
||||
//
|
||||
// The registry cannot write that row for the migration, because the row is the
|
||||
// migration's own last statement inside its own transaction. So the only thing
|
||||
// that can make this true is handing the code to the function - which is why
|
||||
// AppMigrationFunc takes three parameters.
|
||||
func TestForAppRecordsItsAppCode(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["x-1786800001000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for x-1786800001000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "x" {
|
||||
t.Errorf("app_code = %q, want %q", row.AppCode, "x")
|
||||
}
|
||||
}
|
||||
|
||||
// The framework path is untouched: same signature, and an empty app code, which
|
||||
// is what the column defaults to and what every row written before this field
|
||||
// existed reads back as.
|
||||
func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["1786700009000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for 1786700009000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "" {
|
||||
t.Errorf("app_code = %q, want empty (framework)", row.AppCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 12: --app x runs x's migrations and touches nothing else.
|
||||
func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["x"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.ForApp("y").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["y"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
if err := m.MigrateApp("x"); err != nil {
|
||||
t.Fatalf("m.MigrateApp(\"x\"): %v", err)
|
||||
}
|
||||
|
||||
if !ran["x"] {
|
||||
t.Error("x did not run")
|
||||
}
|
||||
if ran["y"] || ran["core"] {
|
||||
t.Errorf("MigrateApp(x) also ran %v", ran)
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sys_migration has %d rows, want 1: %v", len(rows), rows)
|
||||
}
|
||||
}
|
||||
|
||||
// "core" is what status prints for the framework, so --app core has to select
|
||||
// it. The stored code is the empty string; AppFilter is the translation.
|
||||
func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["x"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
if err := m.MigrateApp(FrameworkAppCode); err != nil {
|
||||
t.Fatalf("m.MigrateApp(FrameworkAppCode): %v", err)
|
||||
}
|
||||
|
||||
if !ran["core"] {
|
||||
t.Error("framework migration did not run")
|
||||
}
|
||||
if ran["x"] {
|
||||
t.Error("--app core also ran x")
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-argument Migrate keeps meaning "everything", which is what every
|
||||
// existing caller relies on.
|
||||
func TestMigrateRunsEveryApp(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
var order []string
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
order = append(order, version)
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("bbb").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
order = append(order, version)
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.ForApp("aaa").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
||||
order = append(order, version)
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
// Namespacing puts every framework migration - bare digits - ahead of every
|
||||
// app migration, and orders apps by code rather than by whose timestamp
|
||||
// happened to be smaller. aaa's file is the newer of the two and still runs
|
||||
// first. Cross-app order is not promised, but this is the order, and it is
|
||||
// the one to notice changed.
|
||||
want := []string{"1786700009000", "aaa-1786800002000", "bbb-1786800001000"}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("ran %v, want %v", order, want)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("ran %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two apps minting the same millisecond timestamp used to mean one of them was
|
||||
// read as already applied and silently skipped. The namespace prefix is what
|
||||
// makes that impossible without changing the primary key.
|
||||
func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
const sameTimestamp = "1786800001000"
|
||||
ran := 0
|
||||
for _, app := range []string{"crm", "oms"} {
|
||||
m.ForApp(app).SetVersion(sameTimestamp, func(db *gorm.DB, version, appCode string) error {
|
||||
ran++
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
}
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
if ran != 2 {
|
||||
t.Errorf("ran %d migrations, want 2", ran)
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
for _, want := range []string{"crm-" + sameTimestamp, "oms-" + sameTimestamp} {
|
||||
if _, ok := rows[want]; !ok {
|
||||
t.Errorf("missing %s; got %v", want, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedKeyLeavesFrameworkVersionsBare(t *testing.T) {
|
||||
if got := namespacedKey("", "1786700009000"); got != "1786700009000" {
|
||||
t.Errorf("framework version was rewritten to %q", got)
|
||||
}
|
||||
if got := namespacedKey("crm", "1786800001000"); got != "crm-1786800001000" {
|
||||
t.Errorf("namespacedKey = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An app code differing only in case would group as two apps in status and sort
|
||||
// before every lower-case one, for no reason a reader could guess.
|
||||
func TestForAppNormalisesTheCode(t *testing.T) {
|
||||
m := newMigration()
|
||||
if got := m.ForApp(" CRM ").AppCode(); got != "crm" {
|
||||
t.Errorf("AppCode = %q, want crm", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForAppRejectsReservedCodes(t *testing.T) {
|
||||
for _, code := range []string{"", " ", FrameworkAppCode, "CORE"} {
|
||||
t.Run("code="+code, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("ForApp(%q) did not panic", code)
|
||||
}
|
||||
}()
|
||||
newMigration().ForApp(code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportsPendingAppliedAndOrphaned(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
applied := time.Date(2026, 8, 25, 14, 3, 11, 0, time.UTC)
|
||||
if err := db.Create(&common.Migration{Version: "1786700009000", ApplyTime: applied}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Recorded, but nothing registers it any more.
|
||||
if err := db.Create(&common.Migration{Version: "gone-1786800000000", ApplyTime: applied, AppCode: "gone"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion := map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
|
||||
if e := byVersion["1786700009000"]; !e.Applied || !e.Registered || e.AppCode != "" {
|
||||
t.Errorf("framework entry = %+v", e)
|
||||
} else if e.ApplyTime == nil || !e.ApplyTime.Equal(applied) {
|
||||
t.Errorf("framework apply time = %v, want %v", e.ApplyTime, applied)
|
||||
}
|
||||
if e := byVersion["crm-1786800001000"]; e.Applied || !e.Registered || e.AppCode != "crm" {
|
||||
t.Errorf("crm entry = %+v", e)
|
||||
}
|
||||
if e := byVersion["gone-1786800000000"]; !e.Applied || e.Registered || e.AppCode != "gone" {
|
||||
t.Errorf("orphaned entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 11 rests on this: status and --dry-run both go through Status, and
|
||||
// Status must not create the table it reads.
|
||||
func TestStatusDoesNotCreateItsTable(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status on a database with no sys_migration: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Applied {
|
||||
t.Errorf("entries = %+v, want one pending", entries)
|
||||
}
|
||||
if db.Migrator().HasTable(&common.Migration{}) {
|
||||
t.Error("Status created sys_migration; it must only read")
|
||||
}
|
||||
}
|
||||
|
||||
// The completion row is the migration's own last statement, inside its own
|
||||
// transaction. A migration that fails must leave no record of having run, or
|
||||
// the next run skips it and the schema stays half-changed with nothing to say
|
||||
// so.
|
||||
func TestFailedMigrationLeavesNoRecord(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := recordFor(tx, version, appCode); err != nil {
|
||||
return err
|
||||
}
|
||||
return errTestMigrationFailed
|
||||
})
|
||||
})
|
||||
|
||||
// Driven through the scheduler, not by calling the registered function
|
||||
// directly. That workaround was here because run() called log.Fatal and
|
||||
// would have taken the test binary with it, which also meant nothing
|
||||
// covered what the scheduler does with a failure.
|
||||
if err := m.MigrateApp("crm"); err == nil {
|
||||
t.Fatal("migration reported success")
|
||||
}
|
||||
if rows := rowsByVersion(t, db); len(rows) != 0 {
|
||||
t.Errorf("sys_migration has %v after a failed migration", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// An installer records which version an attempt got stuck on. It gets that
|
||||
// from the error rather than by asking the database what is still pending,
|
||||
// which is a different question - see VersionFailure.
|
||||
func TestRunReportsWhichVersionFailed(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
// Two versions, and the first one succeeds: the failure has to name the
|
||||
// one that actually failed, which a report that just names the app, or
|
||||
// the first version it looked at, would get wrong.
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.ForApp("crm").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
||||
return errTestMigrationFailed
|
||||
})
|
||||
|
||||
err := m.MigrateApp("crm")
|
||||
if err == nil {
|
||||
t.Fatal("MigrateApp reported success")
|
||||
}
|
||||
var vf *VersionFailure
|
||||
if !errors.As(err, &vf) {
|
||||
t.Fatalf("error is %T, want *VersionFailure: %v", err, err)
|
||||
}
|
||||
if vf.Version != "crm-1786800002000" {
|
||||
t.Errorf("failed version = %q, want crm-1786800002000", vf.Version)
|
||||
}
|
||||
if !errors.Is(err, errTestMigrationFailed) {
|
||||
t.Errorf("the cause is not reachable through the wrapper: %v", err)
|
||||
}
|
||||
// The one that succeeded before it stays recorded: a retry must not run
|
||||
// it again.
|
||||
rows := rowsByVersion(t, db)
|
||||
if _, ok := rows["crm-1786800001000"]; !ok {
|
||||
t.Errorf("the migration that succeeded was not recorded: %v", rows)
|
||||
}
|
||||
if _, ok := rows["crm-1786800002000"]; ok {
|
||||
t.Errorf("the migration that failed was recorded: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
var errTestMigrationFailed = &testError{"boom"}
|
||||
|
||||
type testError struct{ s string }
|
||||
|
||||
func (e *testError) Error() string { return e.s }
|
||||
|
||||
// A mistyped --app used to select nothing and print "no migrations to apply",
|
||||
// which reads as "already up to date" - the command reports success and does
|
||||
// nothing, which is the failure mode this whole batch exists to remove.
|
||||
func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
// Reported as an error rather than a log line, so an installer asking
|
||||
// for one app by name cannot be told that installing an app nothing
|
||||
// registered succeeded.
|
||||
err := m.MigrateApp("crmm")
|
||||
if err == nil {
|
||||
t.Fatal("a typo reported success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `no migrations are registered for app "crmm"`) {
|
||||
t.Errorf("error = %q", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "registered: core, crm") {
|
||||
t.Errorf("the message must list what is registered; got %q", err)
|
||||
}
|
||||
if rows := rowsByVersion(t, db); len(rows) != 0 {
|
||||
t.Errorf("a typo ran %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the acceptance test for PRD 006's host-wiring gap: a migration
|
||||
// registered through contract/migration.ForApp - the only door open to a
|
||||
// third-party application - must actually run, be recorded under its app
|
||||
// code, and show up in AppCodes/Status/--app the same as one registered
|
||||
// through the host's own m.ForApp. Before mergedEntries existed, m.Migrate()
|
||||
// never looked at contract/migration's registry at all, so this compiled,
|
||||
// registered, and silently never ran.
|
||||
func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := false
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
if !ran {
|
||||
t.Fatal("contract-registered migration did not run")
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["order-1793800000000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for order-1793800000000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "order" {
|
||||
t.Errorf("app_code = %q, want %q", row.AppCode, "order")
|
||||
}
|
||||
}
|
||||
|
||||
// migrate status and --dry-run both read Status; a contract-registered
|
||||
// migration has to appear there under its app code exactly like a
|
||||
// host-registered one, both before and after it is applied.
|
||||
func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion := map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
e, ok := byVersion["order-1793800000000"]
|
||||
if !ok || !e.Registered || e.Applied || e.AppCode != "order" {
|
||||
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
|
||||
}
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
entries, err = m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion = map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
if e := byVersion["order-1793800000000"]; !e.Applied {
|
||||
t.Errorf("applied contract entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// AppCodes feeds both --app's typo detection (appRegistrationError) and the
|
||||
// group headings status prints; a contract-registered app has to appear
|
||||
// there or a real "go-admin migrate --app order" would be told the app does
|
||||
// not exist.
|
||||
func TestMergedEntriesAppCodesIncludesContractRegisteredApps(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
m := newMigration()
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
got := m.AppCodes()
|
||||
want := []string{"core", "order"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("AppCodes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("AppCodes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --app order has to actually run only order's migrations - the same
|
||||
// per-app isolation MigrateApp already gives host-registered apps - even
|
||||
// though order is registered in a different registry entirely.
|
||||
func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["order"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
if err := m.MigrateApp("order"); err != nil {
|
||||
t.Fatalf("m.MigrateApp(\"order\"): %v", err)
|
||||
}
|
||||
|
||||
if !ran["order"] {
|
||||
t.Error("order did not run")
|
||||
}
|
||||
if ran["core"] {
|
||||
t.Errorf("MigrateApp(order) also ran %v", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// A host-registered key is not supposed to collide with a namespaced
|
||||
// contract key (see mergedEntries' doc comment), but if it somehow did, the
|
||||
// host's own registration must win rather than a third-party application
|
||||
// silently overwriting a framework migration under the same key.
|
||||
func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
hostRan, contractRan := false, false
|
||||
m.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
hostRan = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
reg.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
contractRan = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
if !hostRan {
|
||||
t.Error("host registration did not run")
|
||||
}
|
||||
if contractRan {
|
||||
t.Error("contract registration ran; host registration should have won the collision")
|
||||
}
|
||||
}
|
||||
|
||||
// GetFilename must stay the same rule the contract package applies, since an
|
||||
// application registering through contract/migration names its files by that
|
||||
// convention and has to land on the same version string. Pinning the reject
|
||||
// case is what catches a re-divergence: a local copy that only sliced would
|
||||
// return "add_orders.go" here and register a migration under a key that never
|
||||
// matches anything.
|
||||
func TestGetFilenameDelegatesToTheContractRule(t *testing.T) {
|
||||
if got := GetFilename("version/1786700001000_demo_menu.go"); got != "1786700001000" {
|
||||
t.Fatalf("GetFilename = %q, want %q", got, "1786700001000")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("a file name carrying no version did not panic")
|
||||
}
|
||||
}()
|
||||
GetFilename("version/add_orders.go")
|
||||
}
|
||||
@@ -15,6 +15,14 @@ type Model struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
|
||||
}
|
||||
|
||||
// ModelTime is frozen at the schema shape these tables had before
|
||||
// 1786700003000 converted deleted_at to a NOT NULL millisecond marker. That is
|
||||
// correct for the migrations ordered before the conversion, and wrong for any
|
||||
// added after it: writes put NULL into a NOT NULL column, and reads are scoped
|
||||
// "WHERE deleted_at IS NULL" and match nothing.
|
||||
//
|
||||
// Migrations after that version seed through the runtime models in app/.
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels enforces this.
|
||||
type ModelTime struct {
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
|
||||
|
||||
@@ -13,4 +13,4 @@ type SysApi struct {
|
||||
|
||||
func (SysApi) TableName() string {
|
||||
return "sys_api"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package models
|
||||
|
||||
type SysMenu struct {
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
ControlBy
|
||||
ModelTime
|
||||
}
|
||||
|
||||
func (SysMenu) TableName() string {
|
||||
return "sys_menu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ type SysPost struct {
|
||||
|
||||
func (SysPost) TableName() string {
|
||||
return "sys_post"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ type SysRole struct {
|
||||
|
||||
func (SysRole) TableName() string {
|
||||
return "sys_role"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,13 +237,53 @@ func dropIndexesOn(db *gorm.DB, table, column string) error {
|
||||
if !m.HasIndex(table, name) {
|
||||
continue
|
||||
}
|
||||
if err := m.DropIndex(table, name); err != nil {
|
||||
if err := db.Exec(dropIndex(db, table, name)).Error; err != nil {
|
||||
return fmt.Errorf("dropping index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropIndex spells DROP INDEX for one dialect, rather than going through
|
||||
// Migrator().DropIndex.
|
||||
//
|
||||
// The migrator cannot be used here on PostgreSQL. Its driver resolves a schema
|
||||
// for the statement and falls back to an expression when it cannot:
|
||||
//
|
||||
// currentSchema, _ := m.CurrentSchema(stmt, stmt.Table) // CURRENT_SCHEMA()
|
||||
// m.DB.Exec("DROP INDEX ?.?", currentSchema, clause.Column{Name: name})
|
||||
//
|
||||
// DROP INDEX takes an identifier in that position, not an expression, so the
|
||||
// statement does not parse. The schema is unresolvable for every call made
|
||||
// here, because this passes a table name as a string rather than a model - so
|
||||
// it failed on every PostgreSQL database rather than intermittently, and took
|
||||
// the whole conversion with it. Reported as go-admin#919, where the visible
|
||||
// symptom was a login rejecting a correct password: the migration had stopped
|
||||
// here, leaving deleted_at a timestamptz that the current query compares to 0.
|
||||
//
|
||||
// Written per dialect for the same reason addBigIntColumn and renameColumn
|
||||
// already are.
|
||||
//
|
||||
// MySQL and SQL Server name the table in the statement and have no IF EXISTS
|
||||
// for it; PostgreSQL and SQLite name the index alone, in its own namespace.
|
||||
// The caller has already checked HasIndex, so IF EXISTS is only there to make
|
||||
// the two that support it say nothing rather than fail on a race with another
|
||||
// migrator.
|
||||
//
|
||||
// Verified against PostgreSQL 15, MySQL 8.0 and SQLite. The SQL Server form is
|
||||
// from its documentation and has not been run - this repository has no SQL
|
||||
// Server to run it against.
|
||||
func dropIndex(db *gorm.DB, table, index string) string {
|
||||
switch db.Dialector.Name() {
|
||||
case "mysql":
|
||||
return fmt.Sprintf("DROP INDEX `%s` ON `%s`", index, table)
|
||||
case "sqlserver":
|
||||
return fmt.Sprintf("DROP INDEX [%s] ON [%s]", index, table)
|
||||
default:
|
||||
return fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, index)
|
||||
}
|
||||
}
|
||||
|
||||
// indexNamesFor asks the database which indexes cover column.
|
||||
func indexNamesFor(db *gorm.DB, table, column string) ([]string, error) {
|
||||
indexes, err := db.Migrator().GetIndexes(table)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// postgresDSNEnv points these tests at a database. They are skipped without
|
||||
// it, so a developer with no PostgreSQL running still gets a green run.
|
||||
//
|
||||
// The whole file exists because the rest of this package's tests run on
|
||||
// SQLite, where the defect they cover cannot happen: dropping an index through
|
||||
// gorm's migrator works there and produces unparseable SQL on PostgreSQL. A
|
||||
// suite that only ever exercised SQLite reported success for a migration that
|
||||
// failed on every PostgreSQL database it was pointed at - go-admin#919.
|
||||
const postgresDSNEnv = "GO_ADMIN_TEST_POSTGRES_DSN"
|
||||
|
||||
func postgresDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv(postgresDSNEnv)
|
||||
if dsn == "" {
|
||||
// Skipping locally is the point; skipping in CI is the failure this
|
||||
// file exists to prevent. A workflow that renamed the variable or
|
||||
// dropped the service would otherwise go green while these tests
|
||||
// quietly did nothing - the same shape as the defect they cover.
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the PostgreSQL migration tests must not skip here", postgresDSNEnv)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping the PostgreSQL migration tests", postgresDSNEnv)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("connecting to %s: %v", postgresDSNEnv, err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// pgOldUser is the pre-migration shape: a nullable timestamp with an index on
|
||||
// it, which is what makes dropping the column require dropping the index.
|
||||
type pgOldUser struct {
|
||||
UserId int64 `gorm:"column:user_id;primaryKey;autoIncrement"`
|
||||
Username string
|
||||
DeletedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (pgOldUser) TableName() string { return "sd_pg_user" }
|
||||
|
||||
// The conversion completes on PostgreSQL.
|
||||
//
|
||||
// It did not. dropIndexesOn went through Migrator().DropIndex, whose
|
||||
// PostgreSQL driver falls back to an expression when it cannot resolve a
|
||||
// schema - which is every call made here, because the migration passes a table
|
||||
// name as a string:
|
||||
//
|
||||
// DROP INDEX CURRENT_SCHEMA()."idx_sd_pg_user_deleted_at"
|
||||
//
|
||||
// DROP INDEX takes an identifier there, so it failed to parse and took the
|
||||
// whole conversion with it. Every PostgreSQL deployment stopped at this
|
||||
// migration, and the visible symptom was a login rejecting a correct password
|
||||
// because deleted_at was still a timestamptz being compared to 0.
|
||||
func TestConversionCompletesOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&pgOldUser{}) })
|
||||
|
||||
db.Migrator().DropTable(&pgOldUser{})
|
||||
if err := db.AutoMigrate(&pgOldUser{}); err != nil {
|
||||
t.Fatalf("building the old shape: %v", err)
|
||||
}
|
||||
|
||||
deleted := time.Now().Add(-time.Hour)
|
||||
// Checked rather than fired and forgotten: a failed insert leaves the
|
||||
// assertions below reading an empty table, and "no rows" is a shape some
|
||||
// of them cannot tell from success.
|
||||
if err := db.Create(&pgOldUser{Username: "gone", DeletedAt: &deleted}).Error; err != nil {
|
||||
t.Fatalf("seeding the deleted row: %v", err)
|
||||
}
|
||||
if err := db.Create(&pgOldUser{Username: "live"}).Error; err != nil {
|
||||
t.Fatalf("seeding the live row: %v", err)
|
||||
}
|
||||
|
||||
if err := convertDeletedAt(db, "sd_pg_user"); err != nil {
|
||||
t.Fatalf("convertDeletedAt: %v", err)
|
||||
}
|
||||
|
||||
var dataType string
|
||||
if err := db.Raw(`SELECT data_type FROM information_schema.columns
|
||||
WHERE table_name = 'sd_pg_user' AND column_name = 'deleted_at'`).Scan(&dataType).Error; err != nil {
|
||||
t.Fatalf("reading the column type: %v", err)
|
||||
}
|
||||
if dataType != "bigint" {
|
||||
t.Errorf("deleted_at is %q after the conversion, want bigint", dataType)
|
||||
}
|
||||
|
||||
// The marker has to carry the timestamp across, or a row that was deleted
|
||||
// comes back live.
|
||||
var markers []int64
|
||||
if err := db.Raw(`SELECT deleted_at FROM sd_pg_user ORDER BY user_id`).Scan(&markers).Error; err != nil {
|
||||
t.Fatalf("reading the markers: %v", err)
|
||||
}
|
||||
if len(markers) != 2 {
|
||||
t.Fatalf("read %d rows, want 2", len(markers))
|
||||
}
|
||||
if markers[0] == 0 {
|
||||
t.Error("the deleted row came back live")
|
||||
}
|
||||
if markers[1] != 0 {
|
||||
t.Errorf("the live row is marked deleted at %d", markers[1])
|
||||
}
|
||||
}
|
||||
|
||||
// The index on deleted_at is gone afterwards, which is the step that failed.
|
||||
//
|
||||
// Asserted separately from the conversion because the conversion can succeed
|
||||
// on a table with no index at all, and this migration exists for tables that
|
||||
// have one.
|
||||
func TestTheIndexOnDeletedAtIsDroppedOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&pgOldUser{}) })
|
||||
|
||||
db.Migrator().DropTable(&pgOldUser{})
|
||||
if err := db.AutoMigrate(&pgOldUser{}); err != nil {
|
||||
t.Fatalf("building the old shape: %v", err)
|
||||
}
|
||||
|
||||
var before int64
|
||||
if err := db.Raw(`SELECT count(*) FROM pg_indexes
|
||||
WHERE tablename = 'sd_pg_user' AND indexdef LIKE '%deleted_at%'`).Scan(&before).Error; err != nil {
|
||||
t.Fatalf("counting the indexes before: %v", err)
|
||||
}
|
||||
if before == 0 {
|
||||
t.Fatal("the old shape has no index on deleted_at, so this test asserts nothing")
|
||||
}
|
||||
|
||||
if err := dropIndexesOn(db, "sd_pg_user", "deleted_at"); err != nil {
|
||||
t.Fatalf("dropIndexesOn: %v", err)
|
||||
}
|
||||
|
||||
// This one is why the errors are checked at all rather than as a matter of
|
||||
// habit: a query that fails leaves after at zero, and zero is what success
|
||||
// looks like. An unchecked error here is a test that passes when it cannot
|
||||
// reach the database.
|
||||
var after int64
|
||||
if err := db.Raw(`SELECT count(*) FROM pg_indexes
|
||||
WHERE tablename = 'sd_pg_user' AND indexdef LIKE '%deleted_at%'`).Scan(&after).Error; err != nil {
|
||||
t.Fatalf("counting the indexes after: %v", err)
|
||||
}
|
||||
if after != 0 {
|
||||
t.Errorf("%d index(es) on deleted_at survived", after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Normalize sys_role.data_scope to one of the five values
|
||||
// actions.Permission recognizes, ahead of PRD 006 F14/H2 making its
|
||||
// unrecognized-scope branch fail closed instead of fail open.
|
||||
//
|
||||
// Before that change, an empty or unrecognized data_scope fell into
|
||||
// Permission's default branch, which returned the query untouched - exactly
|
||||
// the same SQL as data_scope "1" (全部数据权限). The seed data shipped
|
||||
// precisely that: config/db.sql's built-in admin role (role_id 1) carries an
|
||||
// empty data_scope rather than "1". Once the default starts matching no
|
||||
// rows instead, that role would silently lose all visibility everywhere
|
||||
// actions.Permission is used, the moment a deployment turns EnableDP on.
|
||||
//
|
||||
// Rewriting every value outside {1,2,3,4,5} to "1" keeps each such role's
|
||||
// effective visibility exactly what it already was - a role that intended a
|
||||
// tighter scope was never getting it under the old fail-open default either,
|
||||
// so this does not tighten anything a deployment was relying on. Whether to
|
||||
// tighten it further is left to whoever owns that role.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700005000NormalizeRoleDataScope)
|
||||
}
|
||||
|
||||
func _1786700005000NormalizeRoleDataScope(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := normalizeRoleDataScope(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeRoleDataScope is split out so tests can run it against a database
|
||||
// that only has sys_role, without also standing up sys_migration.
|
||||
//
|
||||
// The explicit "IS NULL OR" matters: sys_role.data_scope has no NOT NULL
|
||||
// constraint, and SQL's three-valued logic makes `NULL NOT IN (...)`
|
||||
// evaluate to NULL rather than TRUE, so a bare NOT IN clause silently skips
|
||||
// NULL rows instead of normalizing them.
|
||||
func normalizeRoleDataScope(tx *gorm.DB) error {
|
||||
return tx.Exec(
|
||||
"UPDATE sys_role SET data_scope = '1' WHERE data_scope IS NULL OR data_scope NOT IN ('1', '2', '3', '4', '5')",
|
||||
).Error
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type roleDataScopeRow struct {
|
||||
RoleId int `gorm:"column:role_id;primaryKey;autoIncrement"`
|
||||
DataScope string `gorm:"column:data_scope"`
|
||||
}
|
||||
|
||||
func (roleDataScopeRow) TableName() string { return "sys_role" }
|
||||
|
||||
func openRoleTable(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&roleDataScopeRow{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The migration exists because the shipped admin role is exactly this case:
|
||||
// config/db.sql's role_id 1 carries an empty data_scope. Reproduces the seed
|
||||
// data literally rather than a made-up example.
|
||||
func TestNormalizesTheEmptyDataScopeTheSeedDataShips(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// The five recognized values must survive untouched - this migration
|
||||
// normalizes what Permission cannot make sense of, not what it already can.
|
||||
func TestLeavesRecognizedScopesAlone(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
valid := []string{"1", "2", "3", "4", "5"}
|
||||
for i, scope := range valid {
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: i + 1, DataScope: scope}).Error; err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var rows []roleDataScopeRow
|
||||
if err := db.Order("role_id").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
if row.DataScope != valid[i] {
|
||||
t.Errorf("role %d: data_scope = %q, want %q (untouched)", row.RoleId, row.DataScope, valid[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A garbage value (not just empty) must be normalized the same way as empty -
|
||||
// both are "not one of the five", and the migration's WHERE clause has to
|
||||
// catch both.
|
||||
func TestNormalizesGarbageScopesToo(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: "6"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// A NULL data_scope must be normalized too. sys_role.data_scope has no NOT
|
||||
// NULL constraint, and `NULL NOT IN (...)` evaluates to NULL rather than
|
||||
// TRUE under SQL's three-valued logic, so a bare NOT IN clause would leave
|
||||
// this row untouched - the exact gap that let a NULL-scoped role go blind
|
||||
// once Permission's default branch starts fail-closing.
|
||||
func TestNormalizesNullDataScope(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Exec("INSERT INTO sys_role (role_id, data_scope) VALUES (1, NULL)").Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// Running it twice must be safe: it is a plain UPDATE, not DDL, but
|
||||
// sys_migration only records success once, and an operator who reruns
|
||||
// `migrate` on a partially-applied database has to be able to trust that.
|
||||
func TestNormalizeRoleDataScopeIsRepeatable(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Add sys_menu.app_code and sys_api.app_code ahead of PRD 006 F9's Seeder.
|
||||
//
|
||||
// Every row a third-party application's migration writes through
|
||||
// seed.SeedMenus must be attributable to the app that wrote it, so
|
||||
// installing, auditing, or removing one application does not require
|
||||
// guessing which rows belong to it - see go-admin-core's docs/contract.md,
|
||||
// "Application-supplied menu and API entries", for the requirement this
|
||||
// satisfies.
|
||||
//
|
||||
// Ordered after 1786700003000, so importing cmd/migrate/migration/models is
|
||||
// banned here (see schema_coverage_test.go's
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels): AddColumn instead
|
||||
// reads the runtime models' own gorm tags directly, which is also what
|
||||
// makes the column this adds match the one the admin Seeder writes through
|
||||
// those same structs.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700006000AppCodeColumns)
|
||||
}
|
||||
|
||||
func _1786700006000AppCodeColumns(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&adminmodels.SysMenu{}, "AppCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysMenu{}, "AppCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&adminmodels.SysApi{}, "AppCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysApi{}, "AppCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Create sys_app (PRD 008 F2) and sys_app_casbin_grant (F4/F6's casbin
|
||||
// attribution ledger - see the design doc's (docs-prd/008-应用清单与安装器/
|
||||
// 数据库变更.md) §2.2/§3 for why casbin_rule itself is not touched:
|
||||
// gorm-adapter's SavePolicyCtx truncates and reloads that table from its
|
||||
// in-memory model, and any column this migration added to it would be
|
||||
// silently zeroed the first time anything calls SavePolicy.
|
||||
//
|
||||
// Ordered after 1786700003000 (the soft-delete conversion), so importing
|
||||
// cmd/migrate/migration/models is banned here - see
|
||||
// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
// Both new tables are AutoMigrate'd from their runtime model shape under
|
||||
// app/admin/models directly, which is also why neither one is added to
|
||||
// 1786700003000's frozen softDeleteTables list: neither embeds
|
||||
// common.ModelTime in the first place (see design doc §1.1).
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700007000AppRegistryTables)
|
||||
}
|
||||
|
||||
func _1786700007000AppRegistryTables(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().AutoMigrate(
|
||||
new(adminmodels.SysApp),
|
||||
new(adminmodels.SysAppCasbinGrant),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
common "go-admin/common/models"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// postgresDB is defined in 1786700003000_soft_delete_marker_postgres_test.go
|
||||
// and shared across this package's PostgreSQL-only tests.
|
||||
//
|
||||
// This migration is plain AutoMigrate on two brand-new tables, unlike
|
||||
// 1786700003000's DROP INDEX (go-admin#919's actual defect), so there is no
|
||||
// dialect-specific SQL here for AutoMigrate itself to get wrong on
|
||||
// PostgreSQL specifically. What is worth a real PostgreSQL run is
|
||||
// 1786700008000's CONCAT()-based duplicate check next door - PostgreSQL has
|
||||
// had CONCAT() since 9.1, but it was never verified against a real server
|
||||
// until this file, only inferred from documentation - and the same
|
||||
// AutoMigrate call this test makes, so a schema/character-set mistake
|
||||
// AutoMigrate might make silently on a dialect nobody ran it against here
|
||||
// has somewhere to surface.
|
||||
func TestAppRegistryTablesAreCreatedOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
const version = "1786700007000-pg"
|
||||
cleanup := func() {
|
||||
db.Migrator().DropTable(&adminmodels.SysAppCasbinGrant{}, &adminmodels.SysApp{})
|
||||
// Only this test's own row, not the whole shared sys_migration
|
||||
// table: postgresDB points at a real, persistent database (unlike
|
||||
// the SQLite tests' fresh in-memory one per run), so a version left
|
||||
// behind by a previous run of this same binary collides with the
|
||||
// wrapper's own INSERT the next time this test runs.
|
||||
db.Exec("DELETE FROM sys_migration WHERE version = ?", version)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
cleanup()
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate sys_migration: %v", err)
|
||||
}
|
||||
|
||||
if err := _1786700007000AppRegistryTables(db, version); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order", Version: "v1"}).Error; err != nil {
|
||||
t.Fatalf("insert sys_app: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "dup", Version: "v1"}).Error; err == nil {
|
||||
t.Fatal("a second sys_app row with the same app_code was accepted on PostgreSQL")
|
||||
}
|
||||
|
||||
grant := adminmodels.SysAppCasbinGrant{AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET"}
|
||||
if err := db.Create(&grant).Error; err != nil {
|
||||
t.Fatalf("insert sys_app_casbin_grant: %v", err)
|
||||
}
|
||||
dup := grant
|
||||
dup.Id = 0
|
||||
if err := db.Create(&dup).Error; err == nil {
|
||||
t.Fatal("a second sys_app_casbin_grant row with the same natural key was accepted on PostgreSQL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func openAppRegistryDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate sys_migration: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The migration has to build both tables and record itself as applied -
|
||||
// F2/F6's acceptance case is a row landing in either one, and neither is
|
||||
// possible if the table it belongs to was never created.
|
||||
func TestAppRegistryTablesAreCreated(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
|
||||
if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
|
||||
t.Fatal("sys_app was not created")
|
||||
}
|
||||
if !db.Migrator().HasTable(&adminmodels.SysAppCasbinGrant{}) {
|
||||
t.Fatal("sys_app_casbin_grant was not created")
|
||||
}
|
||||
|
||||
// A row that exercises every column, not just HasTable/HasColumn -
|
||||
// AutoMigrate can build a column with the wrong type and still report
|
||||
// that it exists.
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: "order", Name: "Order", Version: "v1", Description: "d", Author: "a",
|
||||
Requires: "payment", Pricing: "free", License: "MIT", Status: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert sys_app: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysAppCasbinGrant{
|
||||
AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert sys_app_casbin_grant: %v", err)
|
||||
}
|
||||
|
||||
var applied common.Migration
|
||||
if err := db.Where("version = ?", "1786700007000").First(&applied).Error; err != nil {
|
||||
t.Fatalf("sys_migration was not recorded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Running it twice must be safe: DDL does not roll back on MySQL, so an
|
||||
// operator whose first attempt failed partway through has nothing to do but
|
||||
// run it again. This calls AutoMigrate directly rather than the wrapper,
|
||||
// which also inserts a sys_migration row that a second call would collide
|
||||
// on - a collision Migrate.run() itself prevents by never calling a
|
||||
// function twice for the same recorded version, so it is not this
|
||||
// migration's job to tolerate.
|
||||
func TestAppRegistryTablesAutoMigrateIsRepeatable(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Migrator().AutoMigrate(
|
||||
new(adminmodels.SysApp),
|
||||
new(adminmodels.SysAppCasbinGrant),
|
||||
); err != nil {
|
||||
t.Fatalf("automigrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sys_app.app_code is the unique key G2 ("is app X installed") answers with
|
||||
// - a second row for the same app code must be rejected, not tolerated.
|
||||
func TestSysAppAppCodeIsUnique(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order", Version: "v1"}).Error; err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order dup", Version: "v1"}).Error; err == nil {
|
||||
t.Fatal("a second sys_app row with the same app_code was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// sys_app_casbin_grant's unique index mirrors casbin_rule's own natural key
|
||||
// (ptype,v0..v5) exactly - see design doc §3. A duplicate grant for the
|
||||
// same rule must be rejected the same way gorm-adapter's own unique index
|
||||
// on casbin_rule would reject it.
|
||||
func TestSysAppCasbinGrantNaturalKeyIsUnique(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
grant := adminmodels.SysAppCasbinGrant{AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET"}
|
||||
if err := db.Create(&grant).Error; err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
dup := grant
|
||||
dup.Id = 0
|
||||
if err := db.Create(&dup).Error; err == nil {
|
||||
t.Fatal("a second sys_app_casbin_grant row with the same natural key was accepted")
|
||||
}
|
||||
|
||||
// A grant for a different app, but the identical casbin natural key, is
|
||||
// exactly the collision two applications granting the same api/role
|
||||
// pair would produce - the natural key has to be the one thing that
|
||||
// rejects it, app_code is descriptive only and not part of the index.
|
||||
other := grant
|
||||
other.Id = 0
|
||||
other.AppCode = "another-app"
|
||||
if err := db.Create(&other).Error; err == nil {
|
||||
t.Fatal("a duplicate natural key under a different app_code was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Give seed.SeedMenus's two write paths (seedApis, seedMenuTree in
|
||||
// app/admin/service/seed.go) a real natural key to check before inserting,
|
||||
// so a retried, partially-failed migration (see the design doc
|
||||
// docs-prd/008-应用清单与安装器/数据库变更.md §1.5/§1.6) does not insert the
|
||||
// same row twice. This has already happened in production once (duplicate
|
||||
// sys_menu/casbin_rule rows on the demo site), not a theoretical risk.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700008000SeedNaturalKeys)
|
||||
}
|
||||
|
||||
func _1786700008000SeedNaturalKeys(db *gorm.DB, version string) error {
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
|
||||
// seedNaturalKeys is split out from the wrapper above so tests can call it
|
||||
// against a database that only has sys_menu/sys_api, without also standing
|
||||
// up sys_migration - and so it can be called more than once in the same
|
||||
// test to prove the re-run tolerance the doc comment above promises: DDL
|
||||
// does not roll back on MySQL, so an operator whose first attempt failed
|
||||
// partway through has nothing to do but run the whole migration again.
|
||||
func seedNaturalKeys(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
|
||||
// sys_menu.seed_code is a brand-new column: every existing row becomes
|
||||
// NULL, and NULL never collides in the unique index built below, so
|
||||
// this needs no pre-check.
|
||||
if !m.HasColumn(&adminmodels.SysMenu{}, "SeedCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysMenu{}, "SeedCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasIndex(&adminmodels.SysMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
if err := db.Exec(uniqueIndexOverNullable(db.Dialector.Name(),
|
||||
"uk_sys_menu_app_seed_code_del", "sys_menu",
|
||||
"app_code, seed_code, deleted_at", "seed_code"),
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sys_api reuses existing, already-populated columns, which the demo
|
||||
// site has already proven can hold duplicates. Refuse rather than let
|
||||
// CREATE UNIQUE INDEX fail on an operator with no idea which rows to
|
||||
// reconcile - same shape as 1786700003000_soft_delete_marker.go's
|
||||
// refuseOnDuplicates.
|
||||
if err := refuseOnDuplicateApis(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
if err := db.Exec(uniqueIndexOverNullable(db.Dialector.Name(),
|
||||
"uk_sys_api_app_path_action_del", "sys_api",
|
||||
"app_code, path, action, deleted_at", "path", "action"),
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueIndexOverNullable builds a CREATE UNIQUE INDEX whose key includes
|
||||
// columns that can be NULL, and makes it mean the same thing on all four
|
||||
// drivers this repository registers.
|
||||
//
|
||||
// Three of them treat two NULLs as different values, so any number of rows
|
||||
// missing one of these columns coexist under the index. SQL Server does not:
|
||||
// its unique index treats NULLs as equal and permits exactly one. The
|
||||
// unfiltered statement therefore fails there on any database with two rows
|
||||
// lacking a seed_code - which is every database, including a brand-new one,
|
||||
// because 1786700001000 seeds five menus and none of them has one:
|
||||
//
|
||||
// Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).
|
||||
//
|
||||
// Adding the filter on SQL Server takes the rows that carry no value out of
|
||||
// the index, which is what the other three do by not comparing their NULLs.
|
||||
// It is not added elsewhere: MySQL has no filtered index at all, and on
|
||||
// PostgreSQL and SQLite it would only restate what those engines already do.
|
||||
//
|
||||
// Only databases that have not applied this migration are affected, and no
|
||||
// SQL Server database can have: it could not get past this statement.
|
||||
//
|
||||
// Takes the dialect by name rather than the connection, so the statement it
|
||||
// builds for every driver can be checked without one of each running.
|
||||
func uniqueIndexOverNullable(dialect, name, table, columns string, nullable ...string) string {
|
||||
stmt := fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s (%s)", name, table, columns)
|
||||
if dialect != "sqlserver" || len(nullable) == 0 {
|
||||
return stmt
|
||||
}
|
||||
preds := make([]string, 0, len(nullable))
|
||||
for _, c := range nullable {
|
||||
preds = append(preds, c+" IS NOT NULL")
|
||||
}
|
||||
return stmt + " WHERE " + strings.Join(preds, " AND ")
|
||||
}
|
||||
|
||||
// refuseOnDuplicateApis reports the (app_code, path, action) values that
|
||||
// would make the unique index impossible, rather than the index failing to
|
||||
// build and saying only that it did. Only live rows count: a soft-deleted
|
||||
// duplicate does not block the index it will never occupy a slot in.
|
||||
//
|
||||
// sys_api.path/action (app/admin/models/sys_api.go) carry no NOT NULL
|
||||
// constraint, and that stays true here on purpose: tightening it is an
|
||||
// independent, backward-incompatible change of its own - existing NULL
|
||||
// rows in a real database would need reconciling or backfilling before
|
||||
// ALTER TABLE ... NOT NULL could even run, which is a decision for
|
||||
// whoever owns that data, not something this migration should force as a
|
||||
// side effect of adding an unrelated index. So this function has to
|
||||
// tolerate NULL path/action rather than assume they cannot occur - see the
|
||||
// query below for how it does that without either crashing on them
|
||||
// (MySQL's CONCAT) or wrongly flagging them (GROUP BY's NULL-equals-NULL).
|
||||
//
|
||||
// The two are independent bugs that happened to share one root cause, and
|
||||
// SQLite's own test suite for this file would have caught neither on its
|
||||
// own: MySQL's CONCAT() returns NULL if any argument is NULL, which turned
|
||||
// a duplicate check against a NULL-holding library into "converting NULL
|
||||
// to string is unsupported" instead of a report - but SQLite's (and
|
||||
// PostgreSQL's) CONCAT() treats a NULL argument as an empty string
|
||||
// instead, so the exact same query never errors there no matter how it is
|
||||
// called. A suite that only ever ran on SQLite would report success for
|
||||
// both defects; only a real MySQL server surfaces the first one at all -
|
||||
// this migration's PostgreSQL-only sibling test file
|
||||
// (1786700008000_seed_natural_keys_postgres_test.go) rules out one more
|
||||
// dialect, but MySQL specifically has to be checked by hand, since this
|
||||
// repository's test suite has no MySQL service to run against in CI.
|
||||
func refuseOnDuplicateApis(db *gorm.DB) error {
|
||||
var dupes []string
|
||||
if err := db.Raw(
|
||||
// This has to agree with what the unique index it guards actually
|
||||
// enforces, not just with what looks like a duplicate at a glance.
|
||||
// Two different SQL rules collide on a NULL: GROUP BY treats two
|
||||
// NULLs as equal, so a naive query flags every pair of rows that
|
||||
// share a NULL path or action - even a pair with only one of the
|
||||
// two NULL, since GROUP BY's equality still holds on whichever
|
||||
// column both rows leave NULL - but a UNIQUE INDEX treats every
|
||||
// NULL as distinct from every other value, including another
|
||||
// NULL, so the index itself accepts every one of those pairs
|
||||
// without complaint. Excluding any row missing either column from
|
||||
// consideration entirely is what makes the two agree: a row
|
||||
// missing path, or missing action, or missing both, can never
|
||||
// violate the index no matter how many other rows are also
|
||||
// missing the same one, so none of them belong in this count.
|
||||
//
|
||||
// No COALESCE: with both columns excluded whenever either is
|
||||
// NULL, CONCAT here never receives a NULL argument for path or
|
||||
// action - app_code cannot be NULL at all (see its own NOT NULL
|
||||
// tag) - so there is nothing left for COALESCE to guard against,
|
||||
// and leaving it out is deliberate rather than an oversight. A
|
||||
// future regression that removed the two IS NOT NULL conditions
|
||||
// above would fail loudly on MySQL (the same Scan error this
|
||||
// query used to produce) instead of quietly reporting a made-up
|
||||
// "duplicate" whose path and action both print as empty - the
|
||||
// failure this function exists to prevent in the first place.
|
||||
`SELECT CONCAT(app_code, '|', path, '|', action) FROM sys_api
|
||||
WHERE deleted_at = 0 AND path IS NOT NULL AND action IS NOT NULL
|
||||
GROUP BY app_code, path, action HAVING COUNT(*) > 1`,
|
||||
).Scan(&dupes).Error; err != nil {
|
||||
return fmt.Errorf("checking sys_api for duplicates: %w", err)
|
||||
}
|
||||
if len(dupes) > 0 {
|
||||
return fmt.Errorf(
|
||||
"sys_api already holds duplicate (app_code,path,action) %v; reconcile them before this migration can add its unique index",
|
||||
dupes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// postgresDB is defined in 1786700003000_soft_delete_marker_postgres_test.go.
|
||||
//
|
||||
// This file exists because refuseOnDuplicateApis's duplicate check is
|
||||
// spelled with CONCAT(), a function this migration's design assumed
|
||||
// PostgreSQL has carried since 9.1 but that nothing had run against a real
|
||||
// PostgreSQL server before this test - only against the pure-Go SQLite
|
||||
// driver, which happens to bundle a SQLite new enough to have grown its own
|
||||
// CONCAT() only recently. A dialect where that assumption were wrong would
|
||||
// otherwise only be discovered the first time an operator's install hit a
|
||||
// genuine sys_api duplicate on PostgreSQL in production.
|
||||
func TestSeedNaturalKeysRefusesDuplicateApisOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{}) })
|
||||
db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{})
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed duplicate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := seedNaturalKeys(db)
|
||||
if err == nil {
|
||||
t.Fatal("PostgreSQL accepted sys_api rows that already hold a duplicate (app_code, path, action)")
|
||||
}
|
||||
if !contains(err.Error(), "order") || !contains(err.Error(), "/api/v1/order") {
|
||||
t.Errorf("the error does not name the offending row: %v", err)
|
||||
}
|
||||
if db.Migrator().HasIndex(&oldSeedApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was built despite the migration refusing")
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY treats two NULLs as equal for grouping; a UNIQUE INDEX treats
|
||||
// every NULL as distinct from every other value, including another NULL.
|
||||
// Both are standard SQL, not a SQLite/PostgreSQL/MySQL difference - this
|
||||
// file exists to confirm that on a real server rather than assume it, the
|
||||
// same reason TestSeedNaturalKeysRefusesDuplicateApisOnPostgres above
|
||||
// exists for CONCAT(). See TestSeedNaturalKeysDoesNotFlagWhatTheIndexWouldAccept
|
||||
// in the SQLite-backed test file for the full account of why this matters:
|
||||
// a naive duplicate check that does not exclude NULL path/action refuses
|
||||
// an install the unique index itself would accept without complaint.
|
||||
func TestSeedNaturalKeysDoesNotFlagWhatTheIndexWouldAcceptOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{}) })
|
||||
db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{})
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', NULL, NULL, 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed NULL row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys refused a library the unique index itself accepts on PostgreSQL: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&oldSeedApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built on PostgreSQL even though seedNaturalKeys reported success")
|
||||
}
|
||||
}
|
||||
|
||||
// The success path, on the same server: both columns and both unique
|
||||
// indexes have to actually build on PostgreSQL, not merely fail to error
|
||||
// out on SQLite. Mirrors TestSeedNaturalKeysIsRepeatable's SQLite coverage.
|
||||
func TestSeedNaturalKeysBuildsOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{}) })
|
||||
db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{})
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !db.Migrator().HasColumn(&oldSeedMenu{}, "seed_code") {
|
||||
t.Error("sys_menu.seed_code was not added on PostgreSQL")
|
||||
}
|
||||
if !db.Migrator().HasIndex(&oldSeedMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
t.Error("the sys_menu unique index was not built on PostgreSQL")
|
||||
}
|
||||
if !db.Migrator().HasIndex(&oldSeedApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the sys_api unique index was not built on PostgreSQL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlserver"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// sqlserverDSNEnv points these tests at a database. They skip without it, so
|
||||
// a developer with no SQL Server running still gets a green run.
|
||||
//
|
||||
// This file exists for the same reason the PostgreSQL one does, one driver
|
||||
// further along. The rest of the package runs on SQLite, where the defect it
|
||||
// covers cannot happen: SQLite, MySQL and PostgreSQL all treat two NULLs in a
|
||||
// unique index as different values, and SQL Server treats them as equal and
|
||||
// permits one. A suite that never pointed at SQL Server reported success for
|
||||
// a migration that could not be applied to any SQL Server database at all,
|
||||
// new or old.
|
||||
const sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN"
|
||||
|
||||
func sqlserverDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv(sqlserverDSNEnv)
|
||||
if dsn == "" {
|
||||
// Skipping locally is the point; skipping in CI is the failure this
|
||||
// file exists to prevent.
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the SQL Server migration tests must not skip here", sqlserverDSNEnv)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping the SQL Server migration tests", sqlserverDSNEnv)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlserver.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("connecting to %s: %v", sqlserverDSNEnv, err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// freshSQLServerTables drops and rebuilds the two tables this migration
|
||||
// touches, so a rerun does not inherit the previous run's index.
|
||||
func freshSQLServerTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, m := range []any{&adminmodels.SysMenu{}, &adminmodels.SysApi{}} {
|
||||
if db.Migrator().HasTable(m) {
|
||||
if err := db.Migrator().DropTable(m); err != nil {
|
||||
t.Fatalf("dropping: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&adminmodels.SysMenu{}, &adminmodels.SysApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The migration completes on SQL Server.
|
||||
//
|
||||
// It did not. Five menus with no seed_code is what 1786700001000 leaves on
|
||||
// every database, and the unfiltered index rejects the second of them:
|
||||
//
|
||||
// Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).
|
||||
func TestSeedNaturalKeysOnSQLServer(t *testing.T) {
|
||||
db := sqlserverDB(t)
|
||||
freshSQLServerTables(t, db)
|
||||
|
||||
// Three rows in the state 1786700006000 leaves behind: an app_code that
|
||||
// defaulted to empty, no seed_code, and live.
|
||||
for _, name := range []string{"one", "two", "three"} {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (menu_name, app_code, deleted_at) VALUES (?, '', 0)", name,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// sys_api's key has two nullable columns and either one is enough to
|
||||
// collide, so both shapes are here. Two rows missing both, and two more
|
||||
// that have a path and no action: a filter naming only path would let
|
||||
// that second pair back into the index, where their equal NULLs collide.
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec("INSERT INTO sys_api (app_code, deleted_at) VALUES ('', 0)").Error; err != nil {
|
||||
t.Fatalf("seeding sys_api: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, deleted_at) VALUES ('', '/api/v1/half', 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding a sys_api row with no action: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys on SQL Server: %v", err)
|
||||
}
|
||||
for _, name := range []string{"uk_sys_menu_app_seed_code_del", "uk_sys_api_app_path_action_del"} {
|
||||
var model any = &adminmodels.SysMenu{}
|
||||
if name == "uk_sys_api_app_path_action_del" {
|
||||
model = &adminmodels.SysApi{}
|
||||
}
|
||||
if !db.Migrator().HasIndex(model, name) {
|
||||
t.Errorf("%s was not created", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows that do carry a seed code still cannot collide - the filter takes
|
||||
// the ones with no value out of the index, it does not turn the index off.
|
||||
code := "dir"
|
||||
first := adminmodels.SysMenu{MenuName: "d1", AppCode: "order", SeedCode: &code}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("first seeded menu: %v", err)
|
||||
}
|
||||
second := adminmodels.SysMenu{MenuName: "d2", AppCode: "order", SeedCode: &code}
|
||||
if err := db.Create(&second).Error; err == nil {
|
||||
t.Error("a duplicate (app_code, seed_code) was accepted; the filtered index is not enforcing anything")
|
||||
}
|
||||
// A different app may reuse the same seed code, which is why the key is
|
||||
// composite in the first place.
|
||||
other := adminmodels.SysMenu{MenuName: "d3", AppCode: "crm", SeedCode: &code}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Errorf("another app could not reuse the seed code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The control. Without the filter the statement fails on this engine, so the
|
||||
// test above is passing because of the fix rather than because SQL Server
|
||||
// turned out not to mind.
|
||||
func TestSQLServerRejectsTheUnfilteredIndex(t *testing.T) {
|
||||
db := sqlserverDB(t)
|
||||
freshSQLServerTables(t, db)
|
||||
|
||||
for _, name := range []string{"one", "two"} {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (menu_name, app_code, deleted_at) VALUES (?, '', 0)", name,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Exec(uniqueIndexOverNullable("postgres",
|
||||
"uk_unfiltered_probe", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")).Error
|
||||
if err == nil {
|
||||
t.Fatal("SQL Server accepted two NULLs in a unique index; the filter this migration adds is not needed")
|
||||
}
|
||||
t.Logf("as expected: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// oldSeedMenu/oldSeedApi are the shape of sys_menu/sys_api immediately
|
||||
// before this migration: post-1786700003000 (deleted_at is the NOT NULL
|
||||
// millisecond marker) and post-1786700006000 (app_code exists), but before
|
||||
// seed_code or either unique index. They stand in for the real runtime
|
||||
// models, which by the time this file is read already carry the columns
|
||||
// this migration adds - the same relationship oldUser bears to sys_user in
|
||||
// 1786700003000_soft_delete_marker_test.go.
|
||||
type oldSeedMenu struct {
|
||||
MenuId int `gorm:"column:menu_id;primaryKey;autoIncrement"`
|
||||
AppCode string `gorm:"column:app_code;type:varchar(64);not null;default:''"`
|
||||
DeletedAt int64 `gorm:"column:deleted_at;not null;default:0"`
|
||||
}
|
||||
|
||||
func (oldSeedMenu) TableName() string { return "sys_menu" }
|
||||
|
||||
type oldSeedApi struct {
|
||||
Id int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
AppCode string `gorm:"column:app_code;type:varchar(64);not null;default:''"`
|
||||
Path string `gorm:"column:path;type:varchar(128)"`
|
||||
Action string `gorm:"column:action;type:varchar(16)"`
|
||||
DeletedAt int64 `gorm:"column:deleted_at;not null;default:0"`
|
||||
}
|
||||
|
||||
func (oldSeedApi) TableName() string { return "sys_api" }
|
||||
|
||||
func openSeedNaturalKeysDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}, &common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The host's own hand-placed menus, and every app-seeded row written
|
||||
// before this column existed, have no seed_code at all - an unbounded
|
||||
// number of those must coexist under the same app_code without tripping
|
||||
// the new unique index (design doc §1.6: "NULL never treated as equal to
|
||||
// NULL").
|
||||
func TestSeedNaturalKeysToleratesManyPreExistingMenusWithNoSeedCode(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Create(&oldSeedMenu{AppCode: ""}).Error; err != nil {
|
||||
t.Fatalf("seed pre-existing menu %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasColumn(&adminmodels.SysMenu{}, "SeedCode") {
|
||||
t.Fatal("sys_menu.seed_code was not added")
|
||||
}
|
||||
}
|
||||
|
||||
// The point of adding seed_code at all: a second row with the same
|
||||
// (app_code, seed_code) while both are live is what seedMenuTree's
|
||||
// idempotency check depends on the database to reject if the Go-level
|
||||
// check above it is ever bypassed or raced.
|
||||
func TestSeedNaturalKeysMenuUniqueIndexBindsLiveRowsOnly(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (app_code, seed_code, deleted_at) VALUES ('order', 'dir', 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
t.Run("a second live row with the same natural key is rejected", func(t *testing.T) {
|
||||
err := db.Exec(
|
||||
"INSERT INTO sys_menu (app_code, seed_code, deleted_at) VALUES ('order', 'dir', 0)",
|
||||
).Error
|
||||
if err == nil {
|
||||
t.Fatal("a duplicate (app_code, seed_code) was accepted while both rows were live")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the key is free again once the row is soft-deleted", func(t *testing.T) {
|
||||
if err := db.Exec("UPDATE sys_menu SET deleted_at = ? WHERE seed_code = 'dir'", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("soft-delete: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (app_code, seed_code, deleted_at) VALUES ('order', 'dir', 0)",
|
||||
).Error; err != nil {
|
||||
t.Errorf("the key stayed taken after its row was soft-deleted: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The demo site has already proven sys_api can hold historical duplicates;
|
||||
// the migration has to name them and refuse, not let CREATE UNIQUE INDEX
|
||||
// fail on an operator with no idea which rows to reconcile.
|
||||
func TestSeedNaturalKeysRefusesDuplicateApis(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed duplicate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := seedNaturalKeys(db)
|
||||
if err == nil {
|
||||
t.Fatal("the migration accepted sys_api rows that already hold a duplicate (app_code, path, action)")
|
||||
}
|
||||
if !contains(err.Error(), "order") || !contains(err.Error(), "/api/v1/order") {
|
||||
t.Errorf("the error does not name the offending row: %v", err)
|
||||
}
|
||||
if db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was built despite the migration refusing")
|
||||
}
|
||||
// sys_menu's column and index are independent of sys_api's outcome and
|
||||
// should already be in place - a partial failure here still leaves a
|
||||
// record of what succeeded, same as any other non-transactional DDL
|
||||
// migration in this package.
|
||||
if !db.Migrator().HasColumn(&adminmodels.SysMenu{}, "SeedCode") {
|
||||
t.Error("sys_menu.seed_code was not added even though only the sys_api step failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Only live rows count towards the duplicate check: a row a prior,
|
||||
// unrelated soft-delete already retired does not block the index it will
|
||||
// never occupy a slot in.
|
||||
func TestSeedNaturalKeysIgnoresSoftDeletedApiDuplicates(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed live row: %v", err)
|
||||
}
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET", DeletedAt: time.Now().UnixMilli()}).Error; err != nil {
|
||||
t.Fatalf("seed soft-deleted row: %v", err)
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built")
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the sys_api index, mirroring
|
||||
// TestSeedNaturalKeysMenuUniqueIndexBindsLiveRowsOnly above: a second live
|
||||
// row is rejected, and the key is free again once the row is
|
||||
// soft-deleted.
|
||||
func TestSeedNaturalKeysApiUniqueIndexBindsLiveRowsOnly(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Run("a second live row with the same natural key is rejected", func(t *testing.T) {
|
||||
err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', '/api/v1/order', 'GET', 0)",
|
||||
).Error
|
||||
if err == nil {
|
||||
t.Fatal("a duplicate (app_code, path, action) was accepted while both rows were live")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the key is free again once the row is soft-deleted", func(t *testing.T) {
|
||||
if err := db.Exec(
|
||||
"UPDATE sys_api SET deleted_at = ? WHERE path = '/api/v1/order'", time.Now().UnixMilli(),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("soft-delete: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', '/api/v1/order', 'GET', 0)",
|
||||
).Error; err != nil {
|
||||
t.Errorf("the key stayed taken after its row was soft-deleted: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Running it twice must be safe: DDL does not roll back on MySQL, so an
|
||||
// operator whose first attempt failed partway through (say, sys_menu's step
|
||||
// succeeded and sys_api's refused) has nothing to do but run the whole
|
||||
// migration again once the duplicates are reconciled.
|
||||
func TestSeedNaturalKeysIsRepeatable(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper's contract with Migrate.run(): the version is only recorded
|
||||
// once the whole thing - both columns, both indexes - succeeded.
|
||||
func TestSeedNaturalKeysWrapperRecordsTheVersion(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := _1786700008000SeedNaturalKeys(db, "1786700008000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
var applied common.Migration
|
||||
if err := db.Where("version = ?", "1786700008000").First(&applied).Error; err != nil {
|
||||
t.Fatalf("sys_migration was not recorded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY treats two NULLs as equal for grouping purposes; a UNIQUE INDEX
|
||||
// treats every NULL as distinct from every other value, including another
|
||||
// NULL - both are standard SQL semantics, not a quirk of one dialect (see
|
||||
// the postgres-only test file next to this one for the same check against
|
||||
// a real server). A duplicate check that groups on the raw columns without
|
||||
// accounting for that difference refuses an install the index itself would
|
||||
// accept without complaint, on data there is nothing to "reconcile" -
|
||||
// worse than the index simply failing to build, because it stops a library
|
||||
// that has nothing wrong with it.
|
||||
//
|
||||
// sys_api.path/action carry no NOT NULL constraint - see the design doc's
|
||||
// note on this migration for why that stays true in this batch, changing
|
||||
// it is an independent, backward-incompatible migration of its own - so
|
||||
// this state is reachable in a real database even though seedApis's own
|
||||
// Create call, which always writes the Go zero value "" rather than NULL,
|
||||
// never produces it itself. Inserted via raw SQL for exactly that reason:
|
||||
// models.SysApi's Path/Action are plain (non-pointer) Go strings, which
|
||||
// cannot represent NULL through a normal Create call.
|
||||
func TestSeedNaturalKeysDoesNotFlagWhatTheIndexWouldAccept(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', NULL, NULL, 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed NULL row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys refused a library the unique index itself accepts: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built even though seedNaturalKeys reported success")
|
||||
}
|
||||
}
|
||||
|
||||
// The case above has both path and action NULL on every row, which both
|
||||
// of the query's two NULL-exclusion conditions independently catch - it
|
||||
// cannot tell "only path IS NOT NULL is doing anything here" apart from
|
||||
// "both conditions are doing something". A row missing only one of the
|
||||
// two is exactly as real (an api registered with a path but no method,
|
||||
// or vice versa) and exercises only one condition at a time: two rows
|
||||
// sharing a real path but both NULL in action, or two rows sharing a real
|
||||
// action but both NULL in path. GROUP BY treats each pair's shared NULL
|
||||
// the same way it treats a shared (NULL, NULL) - as equal - and the
|
||||
// unique index accepts both pairs for the same reason it accepts the
|
||||
// (NULL, NULL) case, so neither belongs in the count either.
|
||||
func TestSeedNaturalKeysDoesNotFlagPartiallyNullRows(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
insert string // two rows, sharing a value in exactly one of path/action
|
||||
}{
|
||||
{
|
||||
name: "path is null, action repeats",
|
||||
insert: "INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', NULL, 'GET', 0)",
|
||||
},
|
||||
{
|
||||
name: "action is null, path repeats",
|
||||
insert: "INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', '/api/v1/order', NULL, 0)",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec(tc.insert).Error; err != nil {
|
||||
t.Fatalf("seed row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys refused a library the unique index itself accepts: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built even though seedNaturalKeys reported success")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The index has to mean the same thing on every driver this repository
|
||||
// registers, and the drivers do not agree about NULL.
|
||||
//
|
||||
// MySQL, PostgreSQL and SQLite treat two NULLs as different values, so any
|
||||
// number of rows missing one of these columns coexist under the index. SQL
|
||||
// Server treats them as equal and permits exactly one, so the unfiltered
|
||||
// statement fails there on any database with two rows lacking a seed_code -
|
||||
// which is every database, a brand-new one included, because 1786700001000
|
||||
// seeds five menus and none of them carries one.
|
||||
func TestUniqueIndexOverNullableFiltersOnlyWhereItHasTo(t *testing.T) {
|
||||
const plain = "CREATE UNIQUE INDEX uk ON sys_menu (app_code, seed_code, deleted_at)"
|
||||
|
||||
for _, dialect := range []string{"mysql", "postgres", "sqlite"} {
|
||||
got := uniqueIndexOverNullable(dialect, "uk", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")
|
||||
if got != plain {
|
||||
t.Errorf("%s: %q\n want %q", dialect, got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")
|
||||
want := plain + " WHERE seed_code IS NOT NULL"
|
||||
if got != want {
|
||||
t.Errorf("sqlserver: %q\n want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// sys_api's key has two nullable columns, and either one being NULL is enough
|
||||
// to collide on SQL Server.
|
||||
func TestUniqueIndexOverNullableCoversEveryNullableColumn(t *testing.T) {
|
||||
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_api",
|
||||
"app_code, path, action, deleted_at", "path", "action")
|
||||
if !strings.HasSuffix(got, " WHERE path IS NOT NULL AND action IS NOT NULL") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A key with nothing nullable in it needs no filter anywhere, or SQL Server
|
||||
// would get a WHERE clause naming no column.
|
||||
func TestUniqueIndexOverNullableWithoutNullableColumns(t *testing.T) {
|
||||
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_menu", "app_code, deleted_at")
|
||||
if strings.Contains(got, "WHERE") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
// The repository carries two ModelTime types. The one in
|
||||
@@ -79,9 +81,13 @@ func runtimeSoftDeleteTables(t *testing.T) map[string]string {
|
||||
}
|
||||
|
||||
func importsRuntimeModels(f *ast.File) bool {
|
||||
return importsPackage(f, "go-admin/common/models")
|
||||
}
|
||||
|
||||
func importsPackage(f *ast.File, pkg string) bool {
|
||||
for _, imp := range f.Imports {
|
||||
p, err := strconv.Unquote(imp.Path.Value)
|
||||
if err == nil && p == "go-admin/common/models" {
|
||||
if err == nil && p == pkg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -171,3 +177,89 @@ func repoRoot(t *testing.T) string {
|
||||
t.Fatal("go.mod not found above the test directory")
|
||||
return ""
|
||||
}
|
||||
|
||||
// softDeleteConversion is the version at which sys_api, sys_menu and the rest
|
||||
// stop storing deleted_at as a nullable timestamp and start storing the NOT
|
||||
// NULL millisecond marker.
|
||||
const softDeleteConversion = 1786700003000
|
||||
|
||||
// versionPrefixLen is the width migration.GetFilename slices off a filename.
|
||||
const versionPrefixLen = 13
|
||||
|
||||
// Migrations ordered after the conversion must not seed rows through
|
||||
// cmd/migrate/migration/models.
|
||||
//
|
||||
// That package's ModelTime still declares a nullable gorm.DeletedAt, which is
|
||||
// correct for the migrations that predate the conversion - it is the shape the
|
||||
// column had when they ran. Reusing it afterwards writes NULL into a NOT NULL
|
||||
// column and the migration fails on its first insert:
|
||||
//
|
||||
// NOT NULL constraint failed: sys_api.deleted_at
|
||||
//
|
||||
// A fresh database never catches this, because every migration using that
|
||||
// package today is ordered before the conversion and so runs while the column
|
||||
// is still nullable. Only a migration added afterwards hits it, which in
|
||||
// practice means the next person adding a business module - the reference
|
||||
// they copy, 1786700001000_demo_menu.go, is itself one of the safe ones.
|
||||
//
|
||||
// Reads through that package are worse than writes, which is why the whole
|
||||
// import is banned rather than just the inserts. gorm scopes a nullable
|
||||
// DeletedAt as "WHERE deleted_at IS NULL", and after the conversion live rows
|
||||
// hold 0, so the row is simply not there:
|
||||
//
|
||||
// frozen SysRole -> record not found
|
||||
// runtime SysRole -> roleId=1
|
||||
//
|
||||
// 1786700001000_demo_menu.go looks the admin role up that way and treats
|
||||
// ErrRecordNotFound as "roles are not seeded yet, skip authorisation". A
|
||||
// post-conversion copy that switched its inserts to the runtime models but
|
||||
// kept this lookup would seed the menu, grant nothing, and still record the
|
||||
// migration as applied - the menu appears, its buttons do nothing, and no
|
||||
// error is reported anywhere.
|
||||
func TestPostConversionMigrationsAvoidFrozenSeedModels(t *testing.T) {
|
||||
const frozenModels = "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
// GetFilename is what every migration uses to derive its own version,
|
||||
// so the two stay in step if the filename convention ever changes.
|
||||
if len(name) < versionPrefixLen {
|
||||
continue
|
||||
}
|
||||
version, err := strconv.ParseInt(migration.GetFilename(name), 10, 64)
|
||||
if err != nil || version <= softDeleteConversion {
|
||||
continue // not a versioned migration, or one that predates the change
|
||||
}
|
||||
checked++
|
||||
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
if importsPackage(f, frozenModels) {
|
||||
t.Errorf("%s is ordered after the soft-delete conversion but seeds through %s;\n"+
|
||||
" that package writes a nullable deleted_at and will fail with\n"+
|
||||
" \"NOT NULL constraint failed\" on its first insert.\n"+
|
||||
" Use the runtime models under app/ instead - they carry the marker.",
|
||||
name, frozenModels)
|
||||
}
|
||||
}
|
||||
|
||||
if checked == 0 {
|
||||
t.Fatal("no post-conversion migrations found; the scan is broken, not the code")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user