服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。 成绩单仅包含正式发布成绩;无已发布成绩时明确提示。 同类型、同用途 5 分钟内重复申请复用已有有效凭证。 申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。 管理员的下载记录、失效、重签能力保持不变。
471 lines
18 KiB
C#
471 lines
18 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using Jiaowu.Api.Controllers;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Auth;
|
|
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Jiaowu.Api.Tests;
|
|
|
|
public sealed class OfficialDocumentTests
|
|
{
|
|
[Theory]
|
|
[InlineData(typeof(IssueOfficialDocumentRequest), "Purpose")]
|
|
[InlineData(typeof(SelfIssueOfficialDocumentRequest), "Purpose")]
|
|
[InlineData(typeof(InvalidateOfficialDocumentRequest), "Reason")]
|
|
[InlineData(typeof(ReissueOfficialDocumentRequest), "Reason")]
|
|
[InlineData(typeof(ReissueOfficialDocumentRequest), "Purpose")]
|
|
public void RequestRecords_DefineValidationOnPrimaryConstructorParameters(
|
|
Type requestType,
|
|
string parameterName)
|
|
{
|
|
var constructor = Assert.Single(requestType.GetConstructors());
|
|
var parameter = Assert.Single(constructor.GetParameters()
|
|
.Where(x => x.Name == parameterName));
|
|
Assert.NotEmpty(parameter.GetCustomAttributes(typeof(ValidationAttribute), false));
|
|
|
|
var property = requestType.GetProperty(parameterName);
|
|
Assert.NotNull(property);
|
|
Assert.Empty(property.GetCustomAttributes(typeof(ValidationAttribute), false));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(OfficialDocumentType.Transcript)]
|
|
[InlineData(OfficialDocumentType.StudentStatusCertificate)]
|
|
public void PdfGenerator_CreatesPdfWithStableHash(OfficialDocumentType type)
|
|
{
|
|
var options = CreateOptions();
|
|
var generator = new OfficialDocumentPdfGenerator(options);
|
|
var snapshot = CreateSnapshot(type);
|
|
|
|
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code");
|
|
|
|
Assert.True(result.Content.Length > 5_000);
|
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
|
|
Assert.Equal(
|
|
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
|
|
result.Sha256);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Service_IssuesTranscriptFromPublishedGradesOnly()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync();
|
|
|
|
var document = await fixture.Service.CreateAsync(
|
|
fixture.Student.Id,
|
|
OfficialDocumentType.Transcript,
|
|
"升学申请",
|
|
fixture.Manager.Id,
|
|
fixture.Manager.DisplayName,
|
|
"https://jw.example.edu",
|
|
null,
|
|
CancellationToken.None);
|
|
|
|
var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson);
|
|
Assert.StartsWith("MXU-TR-", document.DocumentNumber);
|
|
Assert.Equal(64, document.VerificationCodeHash.Length);
|
|
Assert.Equal("升学申请", document.Purpose);
|
|
Assert.Single(snapshot.Grades);
|
|
Assert.Equal("程序设计基础", snapshot.Grades[0].CourseName);
|
|
Assert.Equal(4m, snapshot.EarnedCredits);
|
|
Assert.Equal(3.7m, snapshot.GradePointAverage);
|
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(document.PdfContent, 0, 4));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Download_IsLogged_AndInvalidDocumentCannotBeDownloaded()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync();
|
|
var document = await fixture.IssueAndSaveAsync();
|
|
var controller = fixture.CreateController();
|
|
|
|
var result = await controller.Download(document.Id, CancellationToken.None);
|
|
|
|
Assert.IsType<FileContentResult>(result);
|
|
var log = await fixture.Db.OfficialDocumentDownloads.SingleAsync();
|
|
Assert.Equal(document.Id, log.OfficialDocumentId);
|
|
Assert.Equal(fixture.Manager.Id, log.DownloadedByUserId);
|
|
|
|
document.Status = OfficialDocumentStatus.Invalidated;
|
|
await fixture.Db.SaveChangesAsync();
|
|
var invalidResult = await controller.Download(document.Id, CancellationToken.None);
|
|
Assert.IsType<ConflictObjectResult>(invalidResult);
|
|
Assert.Single(await fixture.Db.OfficialDocumentDownloads.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Verify_ReturnsMaskedIdentityAndCurrentRevocationState()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync();
|
|
const string verificationCode = "1234567890abcdef1234567890abcdef1234567890abcdef";
|
|
var snapshot = CreateSnapshot(OfficialDocumentType.StudentStatusCertificate);
|
|
var document = new OfficialDocument
|
|
{
|
|
DocumentNumber = snapshot.DocumentNumber,
|
|
VerificationCodeHash = OfficialDocumentService.HashVerificationCode(verificationCode),
|
|
Type = snapshot.Type,
|
|
Status = OfficialDocumentStatus.Valid,
|
|
StudentId = fixture.Student.Id,
|
|
IssuedByUserId = fixture.Manager.Id,
|
|
IssuedAt = snapshot.IssuedAt,
|
|
SnapshotJson = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
|
PdfContent = [1, 2, 3],
|
|
PdfSha256 = new string('a', 64)
|
|
};
|
|
fixture.Db.OfficialDocuments.Add(document);
|
|
await fixture.Db.SaveChangesAsync();
|
|
var controller = fixture.CreateController();
|
|
|
|
var action = await controller.Verify(verificationCode, CancellationToken.None);
|
|
var ok = Assert.IsType<OkObjectResult>(action);
|
|
using var json = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
|
Assert.Equal("张*", json.RootElement.GetProperty("StudentName").GetString());
|
|
Assert.Equal("20*****01", json.RootElement.GetProperty("StudentNumber").GetString());
|
|
Assert.True(json.RootElement.GetProperty("Valid").GetBoolean());
|
|
|
|
var numberAction = await controller.Verify(document.DocumentNumber, CancellationToken.None);
|
|
var numberOk = Assert.IsType<OkObjectResult>(numberAction);
|
|
using var numberJson = JsonDocument.Parse(JsonSerializer.Serialize(numberOk.Value));
|
|
Assert.True(numberJson.RootElement.GetProperty("Valid").GetBoolean());
|
|
|
|
document.Status = OfficialDocumentStatus.Invalidated;
|
|
document.InvalidatedAt = DateTime.UtcNow;
|
|
await fixture.Db.SaveChangesAsync();
|
|
action = await controller.Verify(verificationCode, CancellationToken.None);
|
|
ok = Assert.IsType<OkObjectResult>(action);
|
|
using var invalidJson = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
|
Assert.False(invalidJson.RootElement.GetProperty("Valid").GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reissue_SupersedesOriginalAndCreatesSingleLinkedReplacement()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync();
|
|
var original = await fixture.IssueAndSaveAsync();
|
|
var controller = fixture.CreateController();
|
|
|
|
var result = await controller.Reissue(
|
|
original.Id,
|
|
new ReissueOfficialDocumentRequest("成绩更正后重新签发", null),
|
|
CancellationToken.None);
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
fixture.Db.ChangeTracker.Clear();
|
|
var savedOriginal = await fixture.Db.OfficialDocuments.SingleAsync(x => x.Id == original.Id);
|
|
var replacement = await fixture.Db.OfficialDocuments.SingleAsync(
|
|
x => x.ReissuedFromDocumentId == original.Id);
|
|
Assert.Equal(OfficialDocumentStatus.Superseded, savedOriginal.Status);
|
|
Assert.Equal(OfficialDocumentStatus.Valid, replacement.Status);
|
|
Assert.NotEqual(savedOriginal.DocumentNumber, replacement.DocumentNumber);
|
|
Assert.NotEqual(savedOriginal.VerificationCodeHash, replacement.VerificationCodeHash);
|
|
|
|
var duplicate = await controller.Reissue(
|
|
original.Id,
|
|
new ReissueOfficialDocumentRequest("再次重签", null),
|
|
CancellationToken.None);
|
|
Assert.IsType<ConflictObjectResult>(duplicate);
|
|
Assert.Equal(2, await fixture.Db.OfficialDocuments.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StudentCanIssueOwnDocument_AndRecentDuplicateIsReused()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync();
|
|
var controller = fixture.CreateStudentController();
|
|
var request = new SelfIssueOfficialDocumentRequest(
|
|
OfficialDocumentType.Transcript,
|
|
" 实习申请 ");
|
|
|
|
var first = await controller.IssueMine(request, CancellationToken.None);
|
|
|
|
Assert.IsType<CreatedAtActionResult>(first);
|
|
var document = await fixture.Db.OfficialDocuments.SingleAsync();
|
|
Assert.Equal(fixture.Student.Id, document.StudentId);
|
|
Assert.Equal(fixture.StudentUser.Id, document.IssuedByUserId);
|
|
Assert.Equal("实习申请", document.Purpose);
|
|
var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson);
|
|
Assert.Equal("教务处自助签发", snapshot.IssuedByName);
|
|
Assert.Single(snapshot.Grades);
|
|
|
|
var duplicate = await controller.IssueMine(request, CancellationToken.None);
|
|
|
|
Assert.IsType<OkObjectResult>(duplicate);
|
|
Assert.Single(await fixture.Db.OfficialDocuments.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StudentWithoutLinkedRecord_CannotIssueDocument()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync();
|
|
var controller = fixture.CreateStudentController(Guid.NewGuid());
|
|
|
|
var result = await controller.IssueMine(
|
|
new SelfIssueOfficialDocumentRequest(
|
|
OfficialDocumentType.StudentStatusCertificate,
|
|
null),
|
|
CancellationToken.None);
|
|
|
|
Assert.IsType<ConflictObjectResult>(result);
|
|
Assert.Empty(await fixture.Db.OfficialDocuments.ToListAsync());
|
|
}
|
|
|
|
private static OfficialDocumentOptions CreateOptions() => new()
|
|
{
|
|
InstitutionName = "明序大学",
|
|
IssuingOffice = "教务处",
|
|
DocumentNumberPrefix = "MXU"
|
|
};
|
|
|
|
private static OfficialDocumentSnapshot CreateSnapshot(OfficialDocumentType type) => new(
|
|
"明序大学",
|
|
"教务处",
|
|
type == OfficialDocumentType.Transcript
|
|
? "MXU-TR-20260726-ABCDEF123456"
|
|
: "MXU-SC-20260726-ABCDEF123456",
|
|
type,
|
|
new DateTime(2026, 7, 26, 8, 30, 0, DateTimeKind.Utc),
|
|
"教务管理员",
|
|
"升学申请",
|
|
new OfficialStudentSnapshot(
|
|
"202600001",
|
|
"张明",
|
|
"男",
|
|
"计算机学院",
|
|
"计算机科学与技术",
|
|
"计科 2026-1 班",
|
|
2026,
|
|
new DateOnly(2026, 9, 1),
|
|
"在读"),
|
|
type == OfficialDocumentType.Transcript
|
|
? [new OfficialTranscriptRow("2026-2027-1", "CS101", "程序设计基础", 4, 88, 3.7m, "正常")]
|
|
: [],
|
|
type == OfficialDocumentType.Transcript ? 4 : 0,
|
|
type == OfficialDocumentType.Transcript ? 4 : 0,
|
|
type == OfficialDocumentType.Transcript ? 3.7m : null);
|
|
|
|
private sealed class Fixture : IAsyncDisposable
|
|
{
|
|
private readonly SqliteConnection connection;
|
|
private readonly OfficialDocumentOptions options;
|
|
|
|
private Fixture(
|
|
SqliteConnection connection,
|
|
AppDbContext db,
|
|
ApplicationUser manager,
|
|
ApplicationUser studentUser,
|
|
Student student,
|
|
OfficialDocumentOptions options)
|
|
{
|
|
this.connection = connection;
|
|
this.options = options;
|
|
Db = db;
|
|
Manager = manager;
|
|
StudentUser = studentUser;
|
|
Student = student;
|
|
Service = new OfficialDocumentService(
|
|
db,
|
|
options,
|
|
new OfficialDocumentPdfGenerator(options));
|
|
}
|
|
|
|
public AppDbContext Db { get; }
|
|
public ApplicationUser Manager { get; }
|
|
public ApplicationUser StudentUser { get; }
|
|
public Student Student { get; }
|
|
public OfficialDocumentService Service { get; }
|
|
|
|
public static async Task<Fixture> CreateAsync()
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:");
|
|
await connection.OpenAsync();
|
|
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseSqlite(connection)
|
|
.Options);
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
var manager = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserName = "academic-admin",
|
|
NormalizedUserName = "ACADEMIC-ADMIN",
|
|
DisplayName = "教务管理员"
|
|
};
|
|
var studentUser = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserName = "202600001",
|
|
NormalizedUserName = "202600001",
|
|
DisplayName = "张明"
|
|
};
|
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
|
var major = new Major
|
|
{
|
|
Code = "080901",
|
|
Name = "计算机科学与技术",
|
|
DegreeType = "工学学士",
|
|
CollegeId = college.Id
|
|
};
|
|
var administrativeClass = new AdministrativeClass
|
|
{
|
|
Code = "CS2026-1",
|
|
Name = "计科 2026-1 班",
|
|
MajorId = major.Id,
|
|
Grade = 2026
|
|
};
|
|
var student = new Student
|
|
{
|
|
StudentNumber = "202600001",
|
|
Name = "张明",
|
|
Gender = Gender.Male,
|
|
AdministrativeClassId = administrativeClass.Id,
|
|
EnrollmentYear = 2026,
|
|
EnrollmentDate = new DateOnly(2026, 9, 1),
|
|
Status = StudentStatus.Active,
|
|
UserId = studentUser.Id
|
|
};
|
|
var term = new AcademicTerm
|
|
{
|
|
Code = "2026-1",
|
|
Name = "2026-2027 学年第一学期",
|
|
AcademicYear = "2026-2027",
|
|
Season = TermSeason.Autumn,
|
|
StartDate = new DateOnly(2026, 9, 1),
|
|
EndDate = new DateOnly(2027, 1, 20)
|
|
};
|
|
var course = new Course
|
|
{
|
|
Code = "CS101",
|
|
Name = "程序设计基础",
|
|
CollegeId = college.Id,
|
|
Credits = 4,
|
|
TotalHours = 64,
|
|
LectureHours = 48,
|
|
PracticeHours = 16
|
|
};
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = "2026-CS101-01",
|
|
Name = "程序设计基础 01 班",
|
|
AcademicTermId = term.Id,
|
|
CourseId = course.Id,
|
|
Capacity = 40,
|
|
Status = TeachingTaskStatus.Closed
|
|
};
|
|
var published = new GradeSheet
|
|
{
|
|
TeachingTaskId = task.Id,
|
|
Status = GradeSheetStatus.Published,
|
|
Records =
|
|
[
|
|
new GradeRecord
|
|
{
|
|
StudentId = student.Id,
|
|
TotalScore = 88,
|
|
GradePoint = 3.7m
|
|
}
|
|
]
|
|
};
|
|
var draftTask = new TeachingTask
|
|
{
|
|
TaskNumber = "2026-CS102-01",
|
|
Name = "未发布课程",
|
|
AcademicTermId = term.Id,
|
|
CourseId = course.Id,
|
|
Capacity = 40
|
|
};
|
|
var draft = new GradeSheet
|
|
{
|
|
TeachingTaskId = draftTask.Id,
|
|
Status = GradeSheetStatus.Approved,
|
|
Records =
|
|
[
|
|
new GradeRecord
|
|
{
|
|
StudentId = student.Id,
|
|
TotalScore = 99,
|
|
GradePoint = 4m
|
|
}
|
|
]
|
|
};
|
|
db.AddRange(manager, studentUser, college, major, administrativeClass, student, term, course, task,
|
|
published, draftTask, draft);
|
|
await db.SaveChangesAsync();
|
|
return new Fixture(connection, db, manager, studentUser, student, CreateOptions());
|
|
}
|
|
|
|
public async Task<OfficialDocument> IssueAndSaveAsync()
|
|
{
|
|
var document = await Service.CreateAsync(
|
|
Student.Id,
|
|
OfficialDocumentType.Transcript,
|
|
null,
|
|
Manager.Id,
|
|
Manager.DisplayName,
|
|
"https://jw.example.edu",
|
|
null,
|
|
CancellationToken.None);
|
|
Db.OfficialDocuments.Add(document);
|
|
await Db.SaveChangesAsync();
|
|
return document;
|
|
}
|
|
|
|
public OfficialDocumentsController CreateController()
|
|
=> CreateController(new TestDataScope(Manager.Id));
|
|
|
|
public OfficialDocumentsController CreateStudentController(Guid? userId = null)
|
|
=> CreateController(new TestStudentDataScope(userId ?? StudentUser.Id));
|
|
|
|
private OfficialDocumentsController CreateController(ICurrentUserDataScope dataScope)
|
|
{
|
|
var controller = new OfficialDocumentsController(
|
|
Db,
|
|
dataScope,
|
|
Service,
|
|
Options.Create(options));
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext()
|
|
};
|
|
controller.Request.Scheme = "https";
|
|
controller.Request.Host = new HostString("jw.example.edu");
|
|
controller.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
return controller;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Db.DisposeAsync();
|
|
await connection.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private sealed class TestDataScope(Guid managerId) : ICurrentUserDataScope
|
|
{
|
|
public CurrentUserScope Current { get; } = new(
|
|
managerId,
|
|
"教务管理员",
|
|
null,
|
|
DataScope.All,
|
|
new HashSet<string>([SystemRoles.AcademicAdmin]));
|
|
}
|
|
|
|
private sealed class TestStudentDataScope(Guid studentUserId) : ICurrentUserDataScope
|
|
{
|
|
public CurrentUserScope Current { get; } = new(
|
|
studentUserId,
|
|
"张明",
|
|
null,
|
|
DataScope.Self,
|
|
new HashSet<string>([SystemRoles.Student]));
|
|
}
|
|
}
|